@extension.dev/mcp 10.9.0 → 10.10.1

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
@@ -16,9 +16,9 @@ import { fileURLToPath } from "node:url";
16
16
  import { execFile, execFileSync } from "node:child_process";
17
17
  import ws_0 from "ws";
18
18
  import node_http from "node:http";
19
+ import { promisify } from "node:util";
19
20
  import { filterKeysForThisBrowser } from "browser-extension-manifest-fields";
20
21
  import node_net from "node:net";
21
- import { promisify } from "node:util";
22
22
  import { extensionInstall, extensionUninstall, getManagedBrowsersCacheRoot } from "extension-install";
23
23
  var add_feature_namespaceObject = {};
24
24
  __webpack_require__.r(add_feature_namespaceObject);
@@ -91,9 +91,12 @@ __webpack_require__.d(dom_snapshot_namespaceObject, {
91
91
  var eval_namespaceObject = {};
92
92
  __webpack_require__.r(eval_namespaceObject);
93
93
  __webpack_require__.d(eval_namespaceObject, {
94
+ EXTENSION_PAGE_CONTEXTS: ()=>EXTENSION_PAGE_CONTEXTS,
95
+ chromiumManifestVersion: ()=>chromiumManifestVersion,
94
96
  handler: ()=>eval_handler,
95
97
  resolveDefaultEvalContext: ()=>resolveDefaultEvalContext,
96
- schema: ()=>eval_schema
98
+ schema: ()=>eval_schema,
99
+ wantsExtensionPageOverCdp: ()=>wantsExtensionPageOverCdp
97
100
  });
98
101
  var inspect_namespaceObject = {};
99
102
  __webpack_require__.r(inspect_namespaceObject);
@@ -126,6 +129,8 @@ __webpack_require__.d(manifest_validate_namespaceObject, {
126
129
  var open_namespaceObject = {};
127
130
  __webpack_require__.r(open_namespaceObject);
128
131
  __webpack_require__.d(open_namespaceObject, {
132
+ E_USER_GESTURE_REQUIRED: ()=>E_USER_GESTURE_REQUIRED,
133
+ OVERRIDE_SURFACES: ()=>OVERRIDE_SURFACES,
129
134
  SURFACE_MANIFEST_KEYS: ()=>SURFACE_MANIFEST_KEYS,
130
135
  clampPopupBounds: ()=>clampPopupBounds,
131
136
  declaredSurfaces: ()=>declaredSurfaces,
@@ -187,6 +192,7 @@ __webpack_require__.d(start_namespaceObject, {
187
192
  var stop_namespaceObject = {};
188
193
  __webpack_require__.r(stop_namespaceObject);
189
194
  __webpack_require__.d(stop_namespaceObject, {
195
+ contractProcessHints: ()=>contractProcessHints,
190
196
  handler: ()=>stop_handler,
191
197
  schema: ()=>stop_schema,
192
198
  stopOne: ()=>stopOne
@@ -222,7 +228,7 @@ __webpack_require__.d(wait_namespaceObject, {
222
228
  handler: ()=>wait_handler,
223
229
  schema: ()=>wait_schema
224
230
  });
225
- var package_namespaceObject = JSON.parse('{"rE":"10.9.0","El":{"OP":"4.1.2"}}');
231
+ var package_namespaceObject = JSON.parse('{"rE":"10.10.1","El":{"OP":"4.1.30"}}');
226
232
  const ENVELOPE_SCHEMA = 1;
227
233
  const collectWarnings = (warnings)=>{
228
234
  if (!warnings) return [];
@@ -1078,9 +1084,52 @@ function parseBuildIndex(json) {
1078
1084
  }
1079
1085
  return out;
1080
1086
  }
1087
+ const ADDRESS_KEYS = /url|referrer/i;
1088
+ const KEEP_QUERY = /^(utm_|ref$)/;
1089
+ const SCRUB_ORIGIN = "https://www.extension.dev";
1090
+ const REPOSITORY_REF = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+@/;
1091
+ const DEPTH = 4;
1092
+ function maskRepositoryRef(value) {
1093
+ if (!REPOSITORY_REF.test(value)) return value;
1094
+ return `[owner]/[repo]@${value.slice(value.indexOf("@") + 1)}`;
1095
+ }
1096
+ function scrubUrlProperty(value) {
1097
+ if ("string" != typeof value || !value) return value;
1098
+ let parsed;
1099
+ try {
1100
+ parsed = new URL(value, SCRUB_ORIGIN);
1101
+ } catch {
1102
+ return value;
1103
+ }
1104
+ let changed = false;
1105
+ for (const key of [
1106
+ ...parsed.searchParams.keys()
1107
+ ])if (!KEEP_QUERY.test(key)) {
1108
+ parsed.searchParams.delete(key);
1109
+ changed = true;
1110
+ }
1111
+ if (!changed) return value;
1112
+ return value.startsWith("/") ? `${parsed.pathname}${parsed.search}${parsed.hash}` : parsed.toString();
1113
+ }
1114
+ function maskDeep(value, remaining) {
1115
+ if ("string" == typeof value) return maskRepositoryRef(value);
1116
+ if (remaining <= 0 || !value || "object" != typeof value) return value;
1117
+ if (Array.isArray(value)) return value.map((item)=>maskDeep(item, remaining - 1));
1118
+ const masked = {};
1119
+ for (const [key, nested] of Object.entries(value))masked[key] = maskDeep(nested, remaining - 1);
1120
+ return masked;
1121
+ }
1122
+ function sanitizeMcpProperties(properties) {
1123
+ const sanitized = {
1124
+ ...properties
1125
+ };
1126
+ for (const [key, value] of Object.entries(sanitized))if (ADDRESS_KEYS.test(key)) sanitized[key] = scrubUrlProperty(value);
1127
+ for (const [key, value] of Object.entries(sanitized))sanitized[key] = maskDeep(value, DEPTH);
1128
+ return sanitized;
1129
+ }
1081
1130
  const DEFAULT_MEDIA_ORIGIN = "https://media.extension.land";
1082
1131
  const DEFAULT_CHANNEL = "latest";
1083
- const PINNED_COMMIT = "1d04a3d62f4d82ab3f13a3388800d517d1627707";
1132
+ const PINNED_COMMIT = "8e2336852b51c0fb71fd03abeca1f5e93a44700e";
1084
1133
  const CHANNEL_CACHE_TTL_MS = 300000;
1085
1134
  function mediaOrigin() {
1086
1135
  return (process.env.EXTENSION_MEDIA_ORIGIN || "").trim() || DEFAULT_MEDIA_ORIGIN;
@@ -1212,7 +1261,7 @@ function creationFunnelPayload(event, properties, now = new Date()) {
1212
1261
  timestamp: now.toISOString(),
1213
1262
  properties: {
1214
1263
  draft_id: null,
1215
- ...properties,
1264
+ ...sanitizeMcpProperties(properties),
1216
1265
  source: FUNNEL_SOURCE,
1217
1266
  entry: FUNNEL_ENTRY,
1218
1267
  session_id: session,
@@ -1646,6 +1695,42 @@ async function pollDeviceToken(args) {
1646
1695
  };
1647
1696
  }
1648
1697
  }
1698
+ const FREE_ALLOWANCE_PHRASE = "counts against your free allowance";
1699
+ function allowanceWallUrl(apiHint) {
1700
+ return `${mcpOrigins(apiHint).www.replace(/\/+$/, "")}/pricing`;
1701
+ }
1702
+ function allowance_asRecord(value) {
1703
+ return value && "object" == typeof value && !Array.isArray(value) ? value : null;
1704
+ }
1705
+ function asCount(value) {
1706
+ return "number" == typeof value && Number.isFinite(value) && value >= 0 ? value : null;
1707
+ }
1708
+ function readPlatformAllowance(body) {
1709
+ const record = allowance_asRecord(body);
1710
+ if (!record) return null;
1711
+ for (const key of [
1712
+ "allowance",
1713
+ "quota"
1714
+ ]){
1715
+ const nested = allowance_asRecord(record[key]);
1716
+ if (!nested) continue;
1717
+ const used = asCount(nested.used);
1718
+ const limit = asCount(nested.limit);
1719
+ if (null !== used && null !== limit) return {
1720
+ used,
1721
+ limit
1722
+ };
1723
+ }
1724
+ return null;
1725
+ }
1726
+ function spendNarration(options) {
1727
+ const counted = readPlatformAllowance(options.body);
1728
+ return {
1729
+ spent: `${options.what} ran on extension.dev's machines and ${FREE_ALLOWANCE_PHRASE}.`,
1730
+ remains: counted ? `The platform reports ${counted.used} of ${counted.limit} used.` : "The platform sent no remaining count on this call, and this client never invents one. When the allowance runs out, the platform refuses with its own numbers.",
1731
+ wall: `What the free allowance covers and when the paid plan starts are published at ${allowanceWallUrl(options.api)}.`
1732
+ };
1733
+ }
1649
1734
  const project_create_COMMAND = "extension_project_create";
1650
1735
  const FIRST_CALL_BUDGET_MS = 8000;
1651
1736
  const RESUME_BUDGET_MS = 22000;
@@ -1979,6 +2064,11 @@ async function finishFromPoll(poll, ctx) {
1979
2064
  projectId: data.projectId ?? null,
1980
2065
  consoleUrl,
1981
2066
  sourceRepo: ctx.args.repo,
2067
+ allowance: spendNarration({
2068
+ what: "This project creation, including its first build,",
2069
+ body: data,
2070
+ api: ctx.apiBase
2071
+ }),
1982
2072
  nextSteps: [
1983
2073
  `extension_auth (action: login, project: '${finalWorkspace}/${finalProject}')`,
1984
2074
  "extension_publish"
@@ -2122,6 +2212,20 @@ async function searchTemplates(args) {
2122
2212
  });
2123
2213
  }
2124
2214
  const get_template_source_COMMAND = "extension_templates";
2215
+ const TEXTUAL = /\.(json|js|mjs|cjs|ts|tsx|jsx|html|htm|css|scss|sass|less|svg|txt|md|map|vue|svelte|ya?ml)$/i;
2216
+ function encodeTemplateFile(filePath, bytes) {
2217
+ if (TEXTUAL.test(filePath)) {
2218
+ const text = bytes.toString("utf8");
2219
+ if (Buffer.from(text, "utf8").equals(bytes)) return {
2220
+ content: text,
2221
+ encoding: "utf8"
2222
+ };
2223
+ }
2224
+ return {
2225
+ content: bytes.toString("base64"),
2226
+ encoding: "base64"
2227
+ };
2228
+ }
2125
2229
  async function readTemplateSource(args) {
2126
2230
  const template = await getTemplateBySlug(args.slug);
2127
2231
  if (!template) return envelope_envelope({
@@ -2156,6 +2260,7 @@ async function readTemplateSource(args) {
2156
2260
  hint: "Pass specific file paths in the files parameter to read their contents."
2157
2261
  });
2158
2262
  const fileContents = {};
2263
+ const fileEncodings = {};
2159
2264
  const errors = [];
2160
2265
  await Promise.all(args.files.map(async (filePath)=>{
2161
2266
  const urls = await templateFileUrls(args.slug, filePath);
@@ -2163,13 +2268,16 @@ async function readTemplateSource(args) {
2163
2268
  for (const url of urls)try {
2164
2269
  const response = await fetch(url);
2165
2270
  if (response.ok) {
2166
- fileContents[filePath] = await response.text();
2271
+ const encoded = encodeTemplateFile(filePath, Buffer.from(await response.arrayBuffer()));
2272
+ fileContents[filePath] = encoded.content;
2273
+ fileEncodings[filePath] = encoded.encoding;
2167
2274
  return;
2168
2275
  }
2169
2276
  lastStatus = response.status;
2170
2277
  } catch {}
2171
2278
  errors.push(`${filePath}: ${lastStatus || "fetch failed"}`);
2172
2279
  }));
2280
+ const encodedFiles = Object.keys(fileEncodings).filter((filePath)=>"base64" === fileEncodings[filePath]).sort();
2173
2281
  return envelope_envelope({
2174
2282
  ok: 0 === errors.length,
2175
2283
  command: get_template_source_COMMAND,
@@ -2180,8 +2288,10 @@ async function readTemplateSource(args) {
2180
2288
  } : null,
2181
2289
  value: {
2182
2290
  ...meta,
2183
- fileContents
2291
+ fileContents,
2292
+ fileEncodings
2184
2293
  },
2294
+ hint: encodedFiles.length ? `Base64-encoded, not text: ${encodedFiles.join(", ")}. Decode before writing to disk. fileEncodings names the encoding of every file read.` : void 0,
2185
2295
  warnings: errors
2186
2296
  });
2187
2297
  }
@@ -3995,8 +4105,31 @@ function processCommand(pid) {
3995
4105
  return "";
3996
4106
  }
3997
4107
  }
3998
- function sessionProcessPids(projectPath) {
4108
+ function contractProcessHints(projectPath, browser) {
4109
+ try {
4110
+ const contract = JSON.parse(node_fs.readFileSync(bridge_readyContractPath(projectPath, browser), "utf8"));
4111
+ const pids = [
4112
+ contract.browserPid,
4113
+ contract.launcherPid
4114
+ ].filter((pid)=>"number" == typeof pid && Number.isInteger(pid) && pid > 0);
4115
+ return {
4116
+ ..."string" == typeof contract.profilePath && contract.profilePath.trim() ? {
4117
+ profilePath: contract.profilePath
4118
+ } : {},
4119
+ pids
4120
+ };
4121
+ } catch {
4122
+ return {
4123
+ pids: []
4124
+ };
4125
+ }
4126
+ }
4127
+ function sessionProcessPids(projectPath, hints = {
4128
+ pids: []
4129
+ }) {
3999
4130
  const pids = new Set();
4131
+ if (hints.profilePath) for (const pid of pgrepPids(escapeRegex(hints.profilePath)))pids.add(pid);
4132
+ for (const pid of hints.pids)if (pid !== process.pid && isAlive(pid)) pids.add(pid);
4000
4133
  for (const form of projectPathForms(projectPath)){
4001
4134
  const escaped = escapeRegex(form);
4002
4135
  const patterns = [
@@ -4009,8 +4142,10 @@ function sessionProcessPids(projectPath) {
4009
4142
  ...pids
4010
4143
  ].filter((pid)=>PLAUSIBLE_SESSION_BINARY.test(processCommand(pid)));
4011
4144
  }
4012
- function reapSessionProcesses(projectPath) {
4013
- const pids = sessionProcessPids(projectPath);
4145
+ function reapSessionProcesses(projectPath, hints = {
4146
+ pids: []
4147
+ }) {
4148
+ const pids = sessionProcessPids(projectPath, hints);
4014
4149
  for (const pid of pids)try {
4015
4150
  process.kill(pid, "SIGKILL");
4016
4151
  } catch {}
@@ -4049,8 +4184,9 @@ function pidFromReadyContract(projectPath, browser) {
4049
4184
  async function stopOne(projectPath, browser) {
4050
4185
  const session = findSessionInfo(projectPath, browser);
4051
4186
  const pid = session?.pid ?? pidFromReadyContract(projectPath, browser);
4187
+ const hints = contractProcessHints(projectPath, browser);
4052
4188
  if (null == pid) {
4053
- const reaped = reapSessionProcesses(projectPath);
4189
+ const reaped = reapSessionProcesses(projectPath, hints);
4054
4190
  removeSessionMarker(projectPath, browser);
4055
4191
  return {
4056
4192
  projectPath,
@@ -4072,7 +4208,7 @@ async function stopOne(projectPath, browser) {
4072
4208
  }
4073
4209
  detail = isAlive(pid) ? "Sent SIGTERM and SIGKILL but the process still reports alive; it may be exiting." : "Terminated.";
4074
4210
  } else detail = "Process was already gone; cleaned up session records.";
4075
- const reaped = reapSessionProcesses(projectPath);
4211
+ const reaped = reapSessionProcesses(projectPath, hints);
4076
4212
  removeSession(projectPath, browser);
4077
4213
  removeSessionMarker(projectPath, browser);
4078
4214
  try {
@@ -4080,7 +4216,7 @@ async function stopOne(projectPath, browser) {
4080
4216
  force: true
4081
4217
  });
4082
4218
  } catch {}
4083
- const survivors = sessionProcessPids(projectPath);
4219
+ const survivors = sessionProcessPids(projectPath, hints);
4084
4220
  const stopped = !isAlive(pid) && 0 === survivors.length;
4085
4221
  if (survivors.length) detail += ` Warning: ${survivors.length} browser process(es) still alive after reap (pids ${survivors.join(", ")}).`;
4086
4222
  else if (reaped.length) detail += ` Reaped ${reaped.length} browser process(es).`;
@@ -4444,7 +4580,8 @@ async function dev_handler(args) {
4444
4580
  } : {
4445
4581
  requestedPort: args.port ?? 8080
4446
4582
  };
4447
- 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.";
4583
+ const autoAssigned = 0 === args.port;
4584
+ const portNote = null !== boundPort ? void 0 !== args.port && args.port !== boundPort ? autoAssigned ? `Port 0 asked the engine for any free port and it picked ${boundPort} (read from the engine's ready.json contract, the same source extension_wait reports).` : `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 : autoAssigned ? "Port 0 asked the engine for any free port, and it has not stamped its ready.json contract yet, so the port it picked is not known at response time. extension_wait reports it from that contract once it lands." : "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.";
4448
4585
  return envelope_envelope({
4449
4586
  ok: true,
4450
4587
  command: dev_schema.name,
@@ -4634,7 +4771,7 @@ async function start_handler(args) {
4634
4771
  });
4635
4772
  }
4636
4773
  function toMcpSpeak(text) {
4637
- return text.replace(/`?extension dev(?: [^\s`]*)? --browser[= ]([\w-]+) --allow-control`?/g, 'extension_dev with { browser: "$1", allowControl: true }').replace(/--allow-control/g, "allowControl: true (extension_dev)").replace(/--allow-eval/g, "allowEval: true (extension_dev)").replace(/Use --context page --tab <id>/g, 'Use context: "page" (targets the active tab; pass url or tab to pick another)').replace(/--context[= ](background|popup|options|sidebar|devtools|newtab|history|bookmarks|content|page)\b/g, 'context: "$1"').replace(/--tab[= ](\d+|<[\w-]+>)/g, "tab: $1").replace(/--url[= ]"([^"]+)"/g, 'url: "$1"').replace(/--url[= ](<[\w-]+>|\S*(?:\/\/|\*)\S*)/g, 'url: "$1"').replace(/--browser[= ]([\w]+-based|chrome|chromium|edge|brave|opera|vivaldi|yandex|firefox|waterfox|librewolf|safari)\b/g, 'browser: "$1"').replace(/--timeout[= ](\d+)/g, "timeout: $1").replace(/`extension dev`/g, "extension_dev").replace(/\bextension dev\b/g, "extension_dev").replace(/--tab\b/g, "`tab`").replace(/--url\b/g, "`url`").replace(/--context\b/g, "`context`").replace(/--browser\b/g, "`browser`").replace(/--timeout\b/g, "`timeout`");
4774
+ return text.replace(/`?extension dev(?: [^\s`]*)? --browser[= ]([\w-]+) --allow-control`?/g, 'extension_dev with { browser: "$1", allowControl: true }').replace(/--allow-control/g, "allowControl: true (extension_dev)").replace(/--allow-eval/g, "allowEval: true (extension_dev)").replace(/Use --context page --tab <id>/g, 'Use context: "page" (targets the active tab; pass url or tab to pick another)').replace(/--context[= ](background|popup|options|sidebar|devtools|newtab|history|bookmarks|content|page)\b/g, 'context: "$1"').replace(/--tab[= ](\d+|<[\w-]+>)/g, "tab: $1").replace(/--url[= ]"([^"]+)"/g, 'url: "$1"').replace(/--url[= ](<[\w-]+>|\S*(?:\/\/|\*)\S*)/g, 'url: "$1"').replace(/--browser[= ]([\w]+-based|chrome|chromium|edge|brave|opera|vivaldi|yandex|firefox|waterfox|librewolf|safari)\b/g, 'browser: "$1"').replace(/--timeout[= ](\d+)/g, "timeout: $1").replace(/\bextension open (popup|options|sidebar|newtab|history|bookmarks)\b/g, 'extension_open with surface: "$1"').replace(/`extension dev`/g, "extension_dev").replace(/\bextension dev\b/g, "extension_dev").replace(/--tab\b/g, "`tab`").replace(/--url\b/g, "`url`").replace(/--context\b/g, "`context`").replace(/--browser\b/g, "`browser`").replace(/--timeout\b/g, "`timeout`");
4638
4775
  }
4639
4776
  const CONTROL_CHANNEL_DOWN_CODES = new Set([
4640
4777
  "E_CONTROL_DENIED",
@@ -5291,6 +5428,127 @@ function isCdpEndpoint(port) {
5291
5428
  });
5292
5429
  });
5293
5430
  }
5431
+ function webdriver_define_property(obj, key, value) {
5432
+ if (key in obj) Object.defineProperty(obj, key, {
5433
+ value: value,
5434
+ enumerable: true,
5435
+ configurable: true,
5436
+ writable: true
5437
+ });
5438
+ else obj[key] = value;
5439
+ return obj;
5440
+ }
5441
+ const WEBDRIVER_SESSION_MISSING_HINT = "A Safari page is readable only through a safaridriver session the dev session records in ready.json (webdriverPort and webdriverSessionId), and no Extension.js release opens one today: Safari grants one automation session at a time, so this server never opens its own. What Safari does give you is the dev session's log file, which extension_logs and the log-based assertions read once the extension is enabled; the rest is Web Inspector, attended.";
5442
+ function readWebDriverSession(projectPath, browser) {
5443
+ try {
5444
+ const contract = JSON.parse(node_fs.readFileSync(bridge_readyContractPath(projectPath, browser), "utf8"));
5445
+ if ("number" == typeof contract.webdriverPort && Number.isFinite(contract.webdriverPort) && "string" == typeof contract.webdriverSessionId && contract.webdriverSessionId.length > 0) return {
5446
+ port: contract.webdriverPort,
5447
+ sessionId: contract.webdriverSessionId
5448
+ };
5449
+ } catch {}
5450
+ return null;
5451
+ }
5452
+ function readyExtensionId(projectPath, browser) {
5453
+ try {
5454
+ const contract = JSON.parse(node_fs.readFileSync(bridge_readyContractPath(projectPath, browser), "utf8"));
5455
+ const id = String(contract.extensionId || "").trim();
5456
+ return id.length > 0 ? id : null;
5457
+ } catch {
5458
+ return null;
5459
+ }
5460
+ }
5461
+ class WebDriverClient {
5462
+ async call(method, route, body, timeoutMs = 15000) {
5463
+ const controller = new AbortController();
5464
+ const timer = setTimeout(()=>controller.abort(), timeoutMs);
5465
+ try {
5466
+ const response = await this.fetchFn(`${this.base}${route}`, {
5467
+ method,
5468
+ headers: {
5469
+ "content-type": "application/json"
5470
+ },
5471
+ body: "GET" === method ? void 0 : JSON.stringify(body ?? {}),
5472
+ signal: controller.signal
5473
+ });
5474
+ const text = await response.text();
5475
+ let parsed = {};
5476
+ try {
5477
+ parsed = text ? JSON.parse(text) : {};
5478
+ } catch {
5479
+ parsed = {
5480
+ value: {
5481
+ message: text
5482
+ }
5483
+ };
5484
+ }
5485
+ if (!response.ok) {
5486
+ const value = parsed.value || {};
5487
+ throw new Error(String(value.message || value.error || `${response.status} on ${route}`));
5488
+ }
5489
+ return parsed.value;
5490
+ } finally{
5491
+ clearTimeout(timer);
5492
+ }
5493
+ }
5494
+ async alive() {
5495
+ try {
5496
+ await this.call("GET", "/url");
5497
+ return true;
5498
+ } catch {
5499
+ return false;
5500
+ }
5501
+ }
5502
+ async currentUrl() {
5503
+ const value = await this.call("GET", "/url");
5504
+ return "string" == typeof value ? value : null;
5505
+ }
5506
+ async navigate(url, timeoutMs = 60000) {
5507
+ await this.call("POST", "/url", {
5508
+ url
5509
+ }, timeoutMs);
5510
+ }
5511
+ async refresh(timeoutMs = 60000) {
5512
+ await this.call("POST", "/refresh", {}, timeoutMs);
5513
+ }
5514
+ async execute(script, args = [], timeoutMs = 30000) {
5515
+ return this.call("POST", "/execute/sync", {
5516
+ script,
5517
+ args
5518
+ }, timeoutMs);
5519
+ }
5520
+ constructor(info, fetchFn = fetch){
5521
+ webdriver_define_property(this, "info", void 0);
5522
+ webdriver_define_property(this, "fetchFn", void 0);
5523
+ webdriver_define_property(this, "base", void 0);
5524
+ this.info = info;
5525
+ this.fetchFn = fetchFn;
5526
+ this.base = `http://127.0.0.1:${info.port}/session/${info.sessionId}`;
5527
+ }
5528
+ }
5529
+ const EXTENSION_ROOT_READING_SCRIPT = `
5530
+ const nodes = Array.from(document.querySelectorAll('#extension-root,[data-extension-root]'));
5531
+ return {
5532
+ roots: nodes.length,
5533
+ owners: nodes.map((n) => String(n.getAttribute('data-extjs-reinject-owner') || '')),
5534
+ url: String(location.href),
5535
+ title: String(document.title || '')
5536
+ };
5537
+ `;
5538
+ async function readExtensionRoots(client) {
5539
+ const value = await client.execute(EXTENSION_ROOT_READING_SCRIPT);
5540
+ return {
5541
+ roots: "number" == typeof value?.roots ? value.roots : 0,
5542
+ owners: Array.isArray(value?.owners) ? value.owners.map((o)=>String(o)) : [],
5543
+ url: String(value?.url || ""),
5544
+ title: String(value?.title || "")
5545
+ };
5546
+ }
5547
+ function sameDocument(current, wanted) {
5548
+ if (!current) return false;
5549
+ const strip = (u)=>u.replace(/[#?].*$/, "").replace(/\/+$/, "");
5550
+ return strip(current) === strip(wanted);
5551
+ }
5294
5552
  const ENGINE_COMPANION_IDS = new Set([
5295
5553
  "kgdaecdpfkikjncaalnmmnjjfpofkcbl",
5296
5554
  CARRIER_EXTENSION_ID
@@ -5462,7 +5720,48 @@ async function pollForBridgeTab(projectPath, browser, url, budgetMs) {
5462
5720
  await new Promise((r)=>setTimeout(r, 250));
5463
5721
  }
5464
5722
  }
5723
+ const UNKNOWN_VERB = /unknown command/i;
5465
5724
  async function navigateToUrlViaBridge(projectPath, browser, url, timeout, tool = "extension_open") {
5725
+ const viaVerb = await runActVerb([
5726
+ "navigate",
5727
+ url,
5728
+ projectPath,
5729
+ "--browser",
5730
+ browser,
5731
+ ...null != timeout ? [
5732
+ "--timeout",
5733
+ String(timeout)
5734
+ ] : []
5735
+ ], projectPath, timeout, tool);
5736
+ let verbFrame;
5737
+ try {
5738
+ verbFrame = JSON.parse(viaVerb);
5739
+ } catch {
5740
+ verbFrame = null;
5741
+ }
5742
+ const verbUnknown = verbFrame?.ok === false && UNKNOWN_VERB.test(String(verbFrame?.error?.message ?? ""));
5743
+ if (verbFrame && !verbUnknown) {
5744
+ if (false === verbFrame.ok) return actFrameJson(verbFrame.hint ? verbFrame : {
5745
+ ...verbFrame,
5746
+ hint: "URL navigation rides the agent bridge, so the dev session must be started with allowControl: true (extension_dev)."
5747
+ });
5748
+ const value = verbFrame.value && "object" == typeof verbFrame.value ? verbFrame.value : {};
5749
+ return envelope_envelope({
5750
+ ok: true,
5751
+ command: tool,
5752
+ status: "navigated",
5753
+ value: {
5754
+ navigated: url,
5755
+ tabId: "number" == typeof value.tabId ? value.tabId : null,
5756
+ created: true === value.created,
5757
+ via: "navigate"
5758
+ },
5759
+ hint: "The tab now shows this page. Content scripts that match it ran on load; read them with extension_eval (context: 'content', url) or extension_assert content-script-injected."
5760
+ });
5761
+ }
5762
+ return navigateToUrlViaBackgroundEval(projectPath, browser, url, timeout, tool);
5763
+ }
5764
+ async function navigateToUrlViaBackgroundEval(projectPath, browser, url, timeout, tool = "extension_open") {
5466
5765
  const expression = `(async () => { const api = typeof browser !== "undefined" ? browser : chrome; const tabs = await api.tabs.query({ active: true, currentWindow: true }); const active = tabs && tabs[0]; const tab = active && active.id != null ? await api.tabs.update(active.id, { url: ${JSON.stringify(url)} }) : await api.tabs.create({ url: ${JSON.stringify(url)} }); return { tabId: tab && tab.id != null ? tab.id : null }; })()`;
5467
5766
  const raw = await runActVerb([
5468
5767
  "eval",
@@ -5481,7 +5780,7 @@ async function navigateToUrlViaBridge(projectPath, browser, url, timeout, tool =
5481
5780
  const parsed = JSON.parse(raw);
5482
5781
  if (parsed?.ok === false) return actFrameJson(parsed.hint ? parsed : {
5483
5782
  ...parsed,
5484
- hint: "On this browser family URL navigation rides the agent bridge (a background eval of tabs.update), so the dev session must be started with allowEval: true (extension_dev)."
5783
+ hint: "This engine predates the navigate verb, so URL navigation rides a background eval of tabs.update: the dev session must be started with allowEval: true (extension_dev), and an MV3 background that refuses eval by CSP (Safari, strict Chromium builds) cannot navigate this way at all; upgrade the project's Extension.js to one with `extension navigate`."
5485
5784
  });
5486
5785
  } catch {
5487
5786
  return raw;
@@ -5533,6 +5832,244 @@ async function resolveBridgeBaseUrl(projectPath, browser, timeout) {
5533
5832
  } catch {}
5534
5833
  return null;
5535
5834
  }
5835
+ const TARGET_ID_NOTE = "targetId is a CDP target id, NOT a chrome.tabs id: do not pass it as `tab`. Target a tab with `tabUrl` (URL substring) or `url`; if you need a numeric tab id, call extension_dom_snapshot with listTabs: true.";
5836
+ function filterPageTargets(raw) {
5837
+ return raw.filter((t)=>"page" === t.type && !String(t.url ?? "").startsWith("devtools://")).map((t)=>({
5838
+ targetId: String(t.id),
5839
+ type: String(t.type),
5840
+ url: String(t.url ?? ""),
5841
+ title: String(t.title ?? "")
5842
+ }));
5843
+ }
5844
+ async function listPageTargets(port) {
5845
+ return filterPageTargets(await CDPClient.discoverTargets(port));
5846
+ }
5847
+ function matchTargetsByUrl(targets, needle) {
5848
+ const wanted = needle.toLowerCase();
5849
+ const byUrl = targets.filter((t)=>t.url.toLowerCase().includes(wanted));
5850
+ if (byUrl.length > 0) return byUrl;
5851
+ return targets.filter((t)=>t.title.toLowerCase().includes(wanted));
5852
+ }
5853
+ const RUNTIME_EVALUATE_DEFAULTS = {
5854
+ returnByValue: true,
5855
+ awaitPromise: true,
5856
+ userGesture: true,
5857
+ replMode: true
5858
+ };
5859
+ function stripHash(url) {
5860
+ return url.replace(/#.*$/, "");
5861
+ }
5862
+ function matchExtensionPageTargets(targets, wantedUrl) {
5863
+ const wanted = stripHash(wantedUrl);
5864
+ return targets.filter((t)=>{
5865
+ const url = stripHash(t.url);
5866
+ return url === wanted || url.startsWith(wanted);
5867
+ });
5868
+ }
5869
+ async function findExtensionPageTargets(port, wantedUrl) {
5870
+ try {
5871
+ return matchExtensionPageTargets(await listPageTargets(port), wantedUrl);
5872
+ } catch {
5873
+ return [];
5874
+ }
5875
+ }
5876
+ function readRemoteValue(result) {
5877
+ if (!result) return null;
5878
+ if ("value" in result) return result.value;
5879
+ if ("undefined" === result.type) return null;
5880
+ if ("string" == typeof result.unserializableValue) return result.unserializableValue;
5881
+ return result.description ?? null;
5882
+ }
5883
+ function describeException(details) {
5884
+ const exception = details?.exception;
5885
+ if ("string" == typeof exception?.description && exception.description) return exception.description.split("\n")[0];
5886
+ if (exception && "value" in exception) return String(exception.value);
5887
+ return details?.text || "the expression threw";
5888
+ }
5889
+ async function evaluateOnExtensionPage(port, targetId, expression) {
5890
+ const cdp = new CDPClient();
5891
+ try {
5892
+ await cdp.connect(await CDPClient.discoverBrowserWsUrl(port));
5893
+ const sessionId = await cdp.attachToTarget(targetId);
5894
+ const response = await cdp.sendCommand("Runtime.evaluate", {
5895
+ expression,
5896
+ ...RUNTIME_EVALUATE_DEFAULTS
5897
+ }, sessionId);
5898
+ if (response?.exceptionDetails) return {
5899
+ ok: false,
5900
+ thrown: true,
5901
+ message: describeException(response.exceptionDetails)
5902
+ };
5903
+ return {
5904
+ ok: true,
5905
+ value: readRemoteValue(response?.result)
5906
+ };
5907
+ } catch (error) {
5908
+ return {
5909
+ ok: false,
5910
+ thrown: false,
5911
+ message: error instanceof Error ? error.message : String(error)
5912
+ };
5913
+ } finally{
5914
+ try {
5915
+ cdp.disconnect();
5916
+ } catch {}
5917
+ }
5918
+ }
5919
+ const SIDE_PANEL_API_READY = 'typeof chrome !== "undefined" && typeof chrome.sidePanel !== "undefined" && typeof chrome.windows !== "undefined" && document.readyState !== "loading"';
5920
+ const SIDE_PANEL_STATE = "window.__extensionDevSidePanel";
5921
+ const ARM_SIDE_PANEL_SCRIPT = `(async () => {
5922
+ const win = await chrome.windows.getCurrent();
5923
+ const state = { phase: "armed", windowId: win.id };
5924
+ ${SIDE_PANEL_STATE} = state;
5925
+ const overlay = document.createElement("div");
5926
+ overlay.setAttribute("data-extension-dev-gesture", "");
5927
+ overlay.style.cssText = "position:fixed;inset:0;z-index:2147483647;background:transparent;cursor:default;";
5928
+ overlay.addEventListener("click", (event) => {
5929
+ event.stopPropagation();
5930
+ event.preventDefault();
5931
+ state.phase = "clicked";
5932
+ const settle = (phase, message) => {
5933
+ state.phase = phase;
5934
+ if (message) state.message = message;
5935
+ overlay.remove();
5936
+ };
5937
+ try {
5938
+ Promise.resolve(chrome.sidePanel.open({ windowId: win.id }))
5939
+ .then(() => settle("opened"))
5940
+ .catch((error) => settle("failed", String((error && error.message) || error)));
5941
+ } catch (error) {
5942
+ settle("failed", String((error && error.message) || error));
5943
+ }
5944
+ }, { once: true });
5945
+ document.documentElement.appendChild(overlay);
5946
+ return win.id;
5947
+ })()`;
5948
+ const READ_SIDE_PANEL_STATE = `(() => {
5949
+ const state = ${SIDE_PANEL_STATE};
5950
+ return state ? { phase: state.phase, message: state.message } : null;
5951
+ })()`;
5952
+ async function pollUntil(read, budgetMs, everyMs) {
5953
+ const deadline = Date.now() + budgetMs;
5954
+ for(;;){
5955
+ const value = await read();
5956
+ if (null !== value) return value;
5957
+ if (Date.now() >= deadline) return null;
5958
+ await new Promise((r)=>setTimeout(r, everyMs));
5959
+ }
5960
+ }
5961
+ async function openSidePanelWithSyntheticGesture(port, hostUrl) {
5962
+ const cdp = new CDPClient();
5963
+ let hostId = null;
5964
+ const closeHost = async ()=>{
5965
+ if (!hostId) return;
5966
+ try {
5967
+ await cdp.sendCommand("Target.closeTarget", {
5968
+ targetId: hostId
5969
+ });
5970
+ } catch {}
5971
+ };
5972
+ try {
5973
+ await cdp.connect(await CDPClient.discoverBrowserWsUrl(port));
5974
+ const created = await cdp.sendCommand("Target.createTarget", {
5975
+ url: hostUrl
5976
+ });
5977
+ if ("string" != typeof created?.targetId) return {
5978
+ opened: false,
5979
+ reason: `the browser did not open a host tab for ${hostUrl}`
5980
+ };
5981
+ hostId = created.targetId;
5982
+ const sessionId = await cdp.attachToTarget(hostId);
5983
+ await cdp.sendCommand("Runtime.enable", {}, sessionId);
5984
+ const evaluate = async (expression)=>await cdp.sendCommand("Runtime.evaluate", {
5985
+ expression,
5986
+ ...RUNTIME_EVALUATE_DEFAULTS
5987
+ }, sessionId);
5988
+ const apiReady = await pollUntil(async ()=>{
5989
+ const response = await evaluate(SIDE_PANEL_API_READY).catch(()=>null);
5990
+ return response?.result?.value === true ? true : null;
5991
+ }, 5000, 200);
5992
+ if (!apiReady) {
5993
+ await closeHost();
5994
+ return {
5995
+ opened: false,
5996
+ reason: "chrome.sidePanel is not available in the extension's own page (the manifest needs the sidePanel permission and the page must load)"
5997
+ };
5998
+ }
5999
+ const armed = await evaluate(ARM_SIDE_PANEL_SCRIPT);
6000
+ if (armed.exceptionDetails) {
6001
+ await closeHost();
6002
+ return {
6003
+ opened: false,
6004
+ reason: `arming the gesture listener threw: ${describeException(armed.exceptionDetails)}`
6005
+ };
6006
+ }
6007
+ const point = {
6008
+ x: 10,
6009
+ y: 10
6010
+ };
6011
+ await cdp.sendCommand("Input.dispatchMouseEvent", {
6012
+ type: "mouseMoved",
6013
+ ...point
6014
+ }, sessionId);
6015
+ await cdp.sendCommand("Input.dispatchMouseEvent", {
6016
+ type: "mousePressed",
6017
+ button: "left",
6018
+ clickCount: 1,
6019
+ ...point
6020
+ }, sessionId);
6021
+ await cdp.sendCommand("Input.dispatchMouseEvent", {
6022
+ type: "mouseReleased",
6023
+ button: "left",
6024
+ clickCount: 1,
6025
+ ...point
6026
+ }, sessionId);
6027
+ const settled = await pollUntil(async ()=>{
6028
+ const response = await evaluate(READ_SIDE_PANEL_STATE).catch(()=>null);
6029
+ const state = response?.result?.value;
6030
+ if (state?.phase === "opened" || state?.phase === "failed") return state;
6031
+ return null;
6032
+ }, 3000, 100);
6033
+ await closeHost();
6034
+ if (!settled) return {
6035
+ opened: false,
6036
+ reason: "the synthetic click never reached chrome.sidePanel.open (the page did not report a result within 3s)"
6037
+ };
6038
+ if ("failed" === settled.phase) return {
6039
+ opened: false,
6040
+ reason: `chrome.sidePanel.open rejected: ${settled.message ?? "no message"}`
6041
+ };
6042
+ const excluded = hostId;
6043
+ const panel = await pollUntil(async ()=>{
6044
+ const matches = (await findExtensionPageTargets(port, hostUrl)).filter((t)=>t.targetId !== excluded);
6045
+ return matches.length ? matches[0] : null;
6046
+ }, 3000, 250);
6047
+ if (!panel) return {
6048
+ opened: false,
6049
+ reason: `chrome.sidePanel.open resolved but no page target for ${hostUrl} appeared within 3s`
6050
+ };
6051
+ return {
6052
+ opened: true,
6053
+ targetId: panel.targetId,
6054
+ url: panel.url
6055
+ };
6056
+ } catch (error) {
6057
+ await closeHost();
6058
+ return {
6059
+ opened: false,
6060
+ reason: error instanceof Error ? error.message : String(error)
6061
+ };
6062
+ } finally{
6063
+ try {
6064
+ cdp.disconnect();
6065
+ } catch {}
6066
+ }
6067
+ }
6068
+ const OVERRIDE_SURFACES = [
6069
+ "newtab",
6070
+ "history",
6071
+ "bookmarks"
6072
+ ];
5536
6073
  async function pollForTarget(port, url, budgetMs, navigatedTargetId) {
5537
6074
  const deadline = Date.now() + budgetMs;
5538
6075
  const wanted = url.replace(/#.*$/, "");
@@ -5567,7 +6104,38 @@ function isDisposableTab(tabUrl, destination) {
5567
6104
  const origin = destination.match(/^chrome-extension:\/\/[a-p]{32}\//)?.[0];
5568
6105
  return Boolean(origin && tabUrl.startsWith(origin));
5569
6106
  }
6107
+ async function navigateToUrlViaWebDriver(projectPath, browser, url) {
6108
+ const info = readWebDriverSession(projectPath, browser);
6109
+ if (!info) return navigateToUrlViaBridge(projectPath, browser, url);
6110
+ const client = new WebDriverClient(info);
6111
+ try {
6112
+ await client.navigate(url);
6113
+ return envelope_envelope({
6114
+ ok: true,
6115
+ command: open_schema.name,
6116
+ status: "navigated",
6117
+ value: {
6118
+ browser,
6119
+ url: await client.currentUrl().catch(()=>url)
6120
+ },
6121
+ hint: "The Safari automation window now shows this page. Content scripts that match it ran on load; read their DOM with extension_eval or extension_assert."
6122
+ });
6123
+ } catch (error) {
6124
+ return envelope_envelope({
6125
+ ok: false,
6126
+ command: open_schema.name,
6127
+ status: "navigate-failed",
6128
+ error: {
6129
+ code: "E_NAVIGATE_FAILED",
6130
+ name: "NavigateError",
6131
+ message: error instanceof Error ? error.message : String(error)
6132
+ },
6133
+ hint: "The automation window refused the URL or is gone. extension_doctor names which."
6134
+ });
6135
+ }
6136
+ }
5570
6137
  async function navigateToUrl(projectPath, browser, url, timeout) {
6138
+ if (WEBKIT_FAMILY.has(browser) && readWebDriverSession(projectPath, browser)) return navigateToUrlViaWebDriver(projectPath, browser, url);
5571
6139
  if (!isChromiumFamily(browser)) return navigateToUrlViaBridge(projectPath, browser, url, timeout);
5572
6140
  const resolved = await resolveCdpPort(projectPath, browser);
5573
6141
  if (!resolved) return envelope_envelope({
@@ -5892,7 +6460,8 @@ async function openSurfaceAsTab(projectPath, browser, surface) {
5892
6460
  popupBounds = await applyPopupBounds(projectPath, browser, target.targetId);
5893
6461
  if (popupBounds) renderedAsTab.popupBounds = popupBounds;
5894
6462
  }
5895
- parsed.hint = `Rendered the ${surface} document in a real tab, which is how you inspect a surface headlessly. ` + (popupBounds ? `The window was resized to the popup's content size (${popupBounds.width}x${popupBounds.height}${popupBounds.clamped ? ", clamped to Chrome's 25x25-800x600 popup bounds" : ""}), approximating real popup rendering. This resizes the WHOLE browser window for the session. It is the same page with the same extension APIs, but window.close() closes the tab. ` : "It is the same page with the same extension APIs, but it is NOT hosted in a popup window: no popup sizing, and window.close() closes the tab. ") + `Inspect it with extension_dom_snapshot context: '${surface}' (include: ['html']), or extension_inspect with this url. ` + "Do NOT pass this extension-page url to extension_dom_snapshot or extension_eval as a tab target: script injection cannot reach extension pages, only the surface context or CDP can.";
6463
+ const reachIt = `Inspect it with extension_dom_snapshot context: '${surface}' (include: ['html']), or extension_inspect with this url. To run code in it, call extension_eval with context: '${surface}' (on Chromium this goes over CDP, which the extension page CSP does not govern); do NOT pass this extension-page url as a tab target for script injection, which cannot reach extension pages.`;
6464
+ parsed.hint = OVERRIDE_SURFACES.includes(surface) ? `Opened the ${surface} override page in a tab, which is the only place the browser ever renders a chrome_url_overrides page, so this is the real surface and not a stand-in. ` + reachIt : `Rendered the ${surface} document in a real tab, which is how you inspect a surface headlessly. ` + (popupBounds ? `The window was resized to the popup's content size (${popupBounds.width}x${popupBounds.height}${popupBounds.clamped ? ", clamped to Chrome's 25x25-800x600 popup bounds" : ""}), approximating real popup rendering. This resizes the WHOLE browser window for the session. It is the same page with the same extension APIs, but window.close() closes the tab. ` : "It is the same page with the same extension APIs, but it is NOT hosted in a popup window: no popup sizing, and window.close() closes the tab. ") + reachIt;
5896
6465
  return actFrameJson(parsed);
5897
6466
  }
5898
6467
  } catch {}
@@ -5940,7 +6509,7 @@ async function confirmSurfaceTarget(projectPath, browser, surface, raw) {
5940
6509
  }
5941
6510
  const open_schema = {
5942
6511
  name: "extension_open",
5943
- description: "Open an extension surface, or replay an event, in a running session. Pass surface:'popup', 'options' or 'sidebar' to open a UI surface, or 'newtab', 'history' or 'bookmarks' to open the matching chrome_url_overrides page in a tab. Pass surface:'action' to trigger the toolbar action, which opens its popup or replays chrome.action.onClicked when there is none. Pass surface:'command' with `name` to replay a chrome.commands.onCommand shortcut. Note that action and command replay invoke your listener without a user gesture, so the gesture-derived activeTab grant does not apply; the result reports gesture:false and warns when activeTab is declared. Start the session with allowControl:true (extension_dev).",
6512
+ description: "Open an extension surface, or replay an event, in a running session. Pass surface:'popup', 'options' or 'sidebar' to open a UI surface, or 'newtab', 'history' or 'bookmarks' to open the matching chrome_url_overrides page in a tab (always a tab, resolved by the server, never sent to the engine). On Chromium, when Chrome refuses the sidebar for lack of a user gesture, the server opens the real panel through a synthetic click on the extension's own page and says so in warnings; if that fails too it renders the sidebar document as a tab. Pass surface:'action' to trigger the toolbar action, which opens its popup or replays chrome.action.onClicked when there is none. Pass surface:'command' with `name` to replay a chrome.commands.onCommand shortcut. Note that action and command replay invoke your listener without a user gesture, so the gesture-derived activeTab grant does not apply; the result reports gesture:false and warns when activeTab is declared. Start the session with allowControl:true (extension_dev).",
5944
6513
  inputSchema: {
5945
6514
  type: "object",
5946
6515
  properties: {
@@ -5992,10 +6561,9 @@ async function open_handler(args) {
5992
6561
  "popup",
5993
6562
  "options",
5994
6563
  "sidebar",
5995
- "newtab",
5996
- "history",
5997
- "bookmarks"
6564
+ ...OVERRIDE_SURFACES
5998
6565
  ];
6566
+ if (args.surface && OVERRIDE_SURFACES.includes(args.surface)) return openSurfaceAsTab(args.projectPath, browser, args.surface);
5999
6567
  if (args.asTab && args.surface && AS_TAB_SURFACES.includes(args.surface)) return openSurfaceAsTab(args.projectPath, browser, args.surface);
6000
6568
  if (!args.surface) return envelope_envelope({
6001
6569
  ok: false,
@@ -6062,8 +6630,135 @@ async function open_handler(args) {
6062
6630
  return actFrameJson(parsed);
6063
6631
  }
6064
6632
  } catch {}
6633
+ if (!headless && "sidebar" === args.surface && isChromiumFamily(browser)) {
6634
+ const refusal = readGestureRefusal(raw);
6635
+ if (refusal) return openSidebarThroughGesture(args.projectPath, browser, refusal);
6636
+ }
6637
+ if ("sidebar" === args.surface && isGeckoFamily(browser)) {
6638
+ const refusal = readUnsupportedRefusal(raw);
6639
+ if (refusal) return openGeckoSidebar(args.projectPath, browser, refusal, args.timeout);
6640
+ }
6065
6641
  return AS_TAB_SURFACES.includes(args.surface) ? confirmSurfaceTarget(args.projectPath, browser, args.surface, raw) : raw;
6066
6642
  }
6643
+ const E_USER_GESTURE_REQUIRED = "E_USER_GESTURE_REQUIRED";
6644
+ function readGestureRefusal(raw) {
6645
+ let parsed;
6646
+ try {
6647
+ parsed = JSON.parse(raw);
6648
+ } catch {
6649
+ return null;
6650
+ }
6651
+ if (parsed?.ok !== false) return null;
6652
+ const code = "string" == typeof parsed.error?.code ? parsed.error.code : "";
6653
+ const message = String(parsed.error?.message ?? "");
6654
+ return code === E_USER_GESTURE_REQUIRED || /user gesture/i.test(message) ? parsed : null;
6655
+ }
6656
+ function readUnsupportedRefusal(raw) {
6657
+ let parsed;
6658
+ try {
6659
+ parsed = JSON.parse(raw);
6660
+ } catch {
6661
+ return null;
6662
+ }
6663
+ if (parsed?.ok !== false) return null;
6664
+ const code = "string" == typeof parsed.error?.code ? parsed.error.code : "";
6665
+ const message = String(parsed.error?.message ?? "");
6666
+ return "E_NOT_IMPLEMENTED" === code || /not available/i.test(message) ? parsed : null;
6667
+ }
6668
+ const GECKO_SIDEBAR_GESTURE = "Firefox opens a sidebar_action panel only from a user gesture (the toolbar button or View > Sidebar; Bugzilla 1392624), and the engine's open verb carries none, so it cannot open the panel";
6669
+ async function openGeckoSidebar(projectPath, browser, refusal, timeout) {
6670
+ const doc = surfaceDocument(projectPath, browser, "sidebar");
6671
+ if (!doc) return missingSurfaceError(projectPath, browser, "sidebar", "so there is no sidebar panel to open");
6672
+ const probe = await runActVerb([
6673
+ "inspect",
6674
+ projectPath,
6675
+ "--context",
6676
+ "sidebar",
6677
+ "--include",
6678
+ "summary",
6679
+ "--browser",
6680
+ browser,
6681
+ ...null != timeout ? [
6682
+ "--timeout",
6683
+ String(timeout)
6684
+ ] : []
6685
+ ], projectPath, timeout, open_schema.name);
6686
+ let open = null;
6687
+ try {
6688
+ const parsed = JSON.parse(probe);
6689
+ if (parsed?.ok === true) open = parsed;
6690
+ } catch {}
6691
+ if (open) {
6692
+ const url = "string" == typeof open.value?.url ? open.value.url : "string" == typeof open.value?.meta?.url ? open.value.meta.url : void 0;
6693
+ return envelope_envelope({
6694
+ ok: true,
6695
+ command: open_schema.name,
6696
+ status: "already-open",
6697
+ value: {
6698
+ surface: "sidebar",
6699
+ document: doc,
6700
+ alreadyOpen: true,
6701
+ ...url ? {
6702
+ url
6703
+ } : {}
6704
+ },
6705
+ hint: `The sidebar panel is open in the ${browser} window already: read it with extension_dom_snapshot context: 'sidebar' or run code in it with extension_eval context: 'sidebar'. ${GECKO_SIDEBAR_GESTURE}, and it did not need to.`
6706
+ });
6707
+ }
6708
+ const fallback = await openSurfaceAsTab(projectPath, browser, "sidebar");
6709
+ try {
6710
+ const parsedFallback = JSON.parse(fallback);
6711
+ if (parsedFallback?.ok) {
6712
+ addWarning(parsedFallback, `${GECKO_SIDEBAR_GESTURE}, and the panel is not open now, so the sidebar document was rendered as a tab instead. The DOM is the same document the panel would show; the panel hosting stays unverified. A person opens the real panel from the toolbar button or View > Sidebar.`);
6713
+ return actFrameJson(parsedFallback);
6714
+ }
6715
+ } catch {}
6716
+ refusal.error = {
6717
+ ...refusal.error ?? {},
6718
+ message: `${GECKO_SIDEBAR_GESTURE}. Rendering the document ${doc} as a tab failed as well.`
6719
+ };
6720
+ refusal.hint = "Start the session with allowEval: true so the document can be opened by url through the bridge, or open the panel from the toolbar button in the dev browser and read it with extension_dom_snapshot context: 'sidebar'.";
6721
+ return actFrameJson(refusal);
6722
+ }
6723
+ const SIDEBAR_GESTURE_WARNING = "Chrome opens the side panel only from a user gesture and the engine's open verb carries none, so the server opened the extension's own sidebar page in a tab, dispatched a synthetic click on it over CDP and called chrome.sidePanel.open from inside that click, then closed the tab. The panel that opened is the real one; the toolbar wiring (action.onClicked or sidePanel.setPanelBehavior) was not exercised.";
6724
+ async function openSidebarThroughGesture(projectPath, browser, refusal) {
6725
+ const doc = surfaceDocument(projectPath, browser, "sidebar");
6726
+ const resolved = doc ? await resolveCdpPort(projectPath, browser) : null;
6727
+ const extensionId = resolved ? await resolveExtensionId(projectPath, browser) : null;
6728
+ let reason = "the sidebar document or the session's CDP port could not be resolved";
6729
+ if (doc && resolved && extensionId) {
6730
+ const hostUrl = `chrome-extension://${extensionId}/${doc}`;
6731
+ const outcome = await openSidePanelWithSyntheticGesture(resolved.port, hostUrl);
6732
+ if (outcome.opened) return envelope_envelope({
6733
+ ok: true,
6734
+ command: open_schema.name,
6735
+ status: "opened",
6736
+ value: {
6737
+ surface: "sidebar",
6738
+ gesture: "synthetic-click",
6739
+ surfaceTarget: {
6740
+ targetId: outcome.targetId,
6741
+ url: outcome.url
6742
+ }
6743
+ },
6744
+ warnings: [
6745
+ SIDEBAR_GESTURE_WARNING
6746
+ ],
6747
+ hint: "Read the panel with extension_dom_snapshot context: 'sidebar' (include: ['html']) or run code in it with extension_eval context: 'sidebar'. To exercise the toolbar path itself, a person must click the toolbar icon in the dev browser."
6748
+ });
6749
+ reason = outcome.reason;
6750
+ }
6751
+ const fallback = await openSurfaceAsTab(projectPath, browser, "sidebar");
6752
+ try {
6753
+ const parsedFallback = JSON.parse(fallback);
6754
+ if (parsedFallback?.ok) {
6755
+ addWarning(parsedFallback, `Chrome opens the side panel only from a user gesture, the engine's open verb carries none, and the server's synthetic click did not open it either (${reason}), so the sidebar document was rendered as a tab instead. The DOM is the same React tree the panel would show; the panel hosting and the toolbar wiring stay unverified. Opening the real panel needs a toolbar click from a person in the dev browser.`);
6756
+ return actFrameJson(parsedFallback);
6757
+ }
6758
+ } catch {}
6759
+ refusal.hint = `Chrome opens the side panel only from a user gesture, which the engine's open verb cannot carry, and the server's synthetic click did not open it either (${reason}). ` + (doc && extensionId ? `To read the panel page anyway, call extension_open with url: "chrome-extension://${extensionId}/${doc}" and then extension_dom_snapshot context: 'sidebar' on it. ` : "To read the panel page anyway, open its document by url with extension_open and read it with extension_dom_snapshot context: 'sidebar'. ") + "Opening the real panel needs a toolbar click from a person in the dev browser.";
6760
+ return actFrameJson(refusal);
6761
+ }
6067
6762
  function resolveToken() {
6068
6763
  const fromEnv = String(process.env.EXTENSION_DEV_TOKEN || "").trim();
6069
6764
  if (fromEnv) return fromEnv;
@@ -6348,11 +7043,11 @@ const IGNORED_SEGMENTS = new Set([
6348
7043
  "__MACOSX"
6349
7044
  ]);
6350
7045
  const IGNORED_FILES = /^\.env(\..*)?$|\.(pem|key|p12|pfx|keystore)$/i;
6351
- const TEXTUAL = /\.(json|js|mjs|cjs|ts|tsx|jsx|html|htm|css|svg|txt|md|map)$/i;
7046
+ const preview_upload_TEXTUAL = /\.(json|js|mjs|cjs|ts|tsx|jsx|html|htm|css|svg|txt|md|map)$/i;
6352
7047
  const MAX_FILES = 2000;
6353
7048
  const MAX_CONTENT_CHARS = 67108864;
6354
7049
  function encodeFile(relativePath, bytes) {
6355
- if (TEXTUAL.test(relativePath)) {
7050
+ if (preview_upload_TEXTUAL.test(relativePath)) {
6356
7051
  const text = bytes.toString("utf8");
6357
7052
  if (Buffer.from(text, "utf8").equals(bytes)) return {
6358
7053
  content: text,
@@ -6518,6 +7213,7 @@ async function uploadPreview(options) {
6518
7213
  };
6519
7214
  return {
6520
7215
  ok: true,
7216
+ body: data,
6521
7217
  data: {
6522
7218
  artifactId,
6523
7219
  previewUrl,
@@ -6806,6 +7502,21 @@ function localLaneRemedy(checkout) {
6806
7502
  const shareOut = "Pass share:true for a link that needs no local server and opens for anyone.";
6807
7503
  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}`;
6808
7504
  }
7505
+ function unrequestedShare(args) {
7506
+ const call = {
7507
+ projectPath: args.projectPath
7508
+ };
7509
+ if (args.browser) call.browser = args.browser;
7510
+ if (false === args.build) call.build = false;
7511
+ if (args.distPath) call.distPath = args.distPath;
7512
+ call.share = true;
7513
+ return {
7514
+ requested: false,
7515
+ localLinkNeeds: `deepLink renders only against a ${SURFACE.label} dev server running on this machine, so to anyone else it is a dead link.`,
7516
+ shareSpends: "share:true uploads this build to extension.dev's machines and counts against your free allowance, which is why it is off by default.",
7517
+ shareCall: `extension_preview_web ${JSON.stringify(call)}`
7518
+ };
7519
+ }
6809
7520
  function previewOriginOf(previewUrl) {
6810
7521
  try {
6811
7522
  return new URL(previewUrl).origin;
@@ -6883,6 +7594,10 @@ async function buildShare(projectPath, distDir, manifest, browser, verifyInBrows
6883
7594
  } : {},
6884
7595
  serves: "uploaded-local-build",
6885
7596
  localBuildUploaded: true,
7597
+ allowance: spendNarration({
7598
+ what: "This share upload",
7599
+ body: result.body
7600
+ }),
6886
7601
  ...browserCheck ? browserCheck.held ? {
6887
7602
  browserLoadable: null,
6888
7603
  heldFromPublic: true,
@@ -6959,7 +7674,7 @@ const preview_web_schema = {
6959
7674
  share: {
6960
7675
  type: "boolean",
6961
7676
  default: false,
6962
- 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."
7677
+ 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. Uploading runs on extension.dev's machines and counts against your free allowance; left false, the result's share property says what the local deepLink needs, what share:true spends, and the exact call to get a shareable link. 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."
6963
7678
  }
6964
7679
  },
6965
7680
  required: [
@@ -7064,6 +7779,7 @@ async function preview_web_handler(args) {
7064
7779
  built: false
7065
7780
  }
7066
7781
  };
7782
+ if (!args.share) result.share = unrequestedShare(args);
7067
7783
  const checkout = previewDevCheckout([
7068
7784
  args.projectPath,
7069
7785
  process.cwd()
@@ -8112,6 +8828,81 @@ function verdictSentence(verdict) {
8112
8828
  if (verdict.inconclusive.length > 0) parts.push(`${verdict.inconclusive.length} inconclusive, meaning this platform cannot cover the question today: not a pass, not a bug in the extension, and each one carries the evidence that would settle it in settledBy`);
8113
8829
  return `${parts.join(", ")}.`;
8114
8830
  }
8831
+ const execFileAsync = promisify(execFile);
8832
+ const SAFARI_MCP_SETTING = 'Safari > Settings > Developer > "Allow remote automation and external agents"';
8833
+ const SAFARI_MCP_ADD_COMMAND = 'claude mcp add safari-mcp -- "/usr/bin/safaridriver" --mcp';
8834
+ function parseSafariDriverHelp(helpText) {
8835
+ const flags = new Set(Array.from(helpText.matchAll(/(?:^|\s)--([a-z][a-z-]*)/g)).map((m)=>m[1]));
8836
+ return {
8837
+ mcp: flags.has("mcp"),
8838
+ bidi: flags.has("bidi")
8839
+ };
8840
+ }
8841
+ function safariDriverCandidates(safariBinary) {
8842
+ const candidates = [];
8843
+ if (safariBinary) candidates.push(node_path.join(node_path.dirname(safariBinary), "safaridriver"));
8844
+ candidates.push("/usr/bin/safaridriver");
8845
+ return Array.from(new Set(candidates));
8846
+ }
8847
+ async function readSafariDriverHelp(driverPath) {
8848
+ try {
8849
+ const { stdout, stderr } = await execFileAsync(driverPath, [
8850
+ "--help"
8851
+ ], {
8852
+ timeout: 5000,
8853
+ env: {
8854
+ ...process.env
8855
+ }
8856
+ });
8857
+ return `${stdout}\n${stderr}`;
8858
+ } catch (error) {
8859
+ const failed = error;
8860
+ const text = `${failed.stdout ?? ""}\n${failed.stderr ?? ""}`.trim();
8861
+ return text.length > 0 ? text : null;
8862
+ }
8863
+ }
8864
+ async function detectSafariAutomation(safariBinary) {
8865
+ if ("darwin" !== process.platform) return {
8866
+ safaridriver: null,
8867
+ mcp: false,
8868
+ bidi: false
8869
+ };
8870
+ for (const candidate of safariDriverCandidates(safariBinary)){
8871
+ if (!node_fs.existsSync(candidate)) continue;
8872
+ const help = await readSafariDriverHelp(candidate);
8873
+ if (null !== help) return {
8874
+ safaridriver: candidate,
8875
+ ...parseSafariDriverHelp(help)
8876
+ };
8877
+ }
8878
+ return {
8879
+ safaridriver: null,
8880
+ mcp: false,
8881
+ bidi: false
8882
+ };
8883
+ }
8884
+ async function readSafariVersion(safariBinary) {
8885
+ const plist = node_path.resolve(safariBinary, "..", "..", "Info.plist");
8886
+ if (!node_fs.existsSync(plist)) return null;
8887
+ try {
8888
+ const { stdout } = await execFileAsync("/usr/bin/defaults", [
8889
+ "read",
8890
+ plist,
8891
+ "CFBundleShortVersionString"
8892
+ ], {
8893
+ timeout: 5000
8894
+ });
8895
+ const version = stdout.trim();
8896
+ return /^\d+(\.\d+)*$/.test(version) ? version : null;
8897
+ } catch {
8898
+ return null;
8899
+ }
8900
+ }
8901
+ function safariAutomationHint(automation) {
8902
+ if (automation.mcp) return `Safari ships Apple's Safari MCP server (${automation.safaridriver} --mcp). Enable ${SAFARI_MCP_SETTING}, then add it beside this server: ${SAFARI_MCP_ADD_COMMAND}. It drives an isolated automation window with page-level tools (tabs, console, network, screenshots, evaluate); it has no extension-aware tool, so use it to read a page your content script touches, not the popup or background.`;
8903
+ if (automation.safaridriver) return `This safaridriver (${automation.safaridriver}) has no --mcp flag: Apple's Safari MCP server needs Safari 27 or Safari Technology Preview 247+. Until then a Safari session is build, open and enable only, with no console or DOM reading from this server.`;
8904
+ return "No safaridriver found beside Safari, so no automation reading is possible for it from this machine.";
8905
+ }
8115
8906
  function makeFilter(args) {
8116
8907
  const level = String(args.level || "all").toLowerCase();
8117
8908
  const loggingOff = "off" === level;
@@ -8195,6 +8986,16 @@ async function environmentPreflight() {
8195
8986
  status: cacheExists ? "pass" : "warn",
8196
8987
  detail: cacheExists ? `Template catalog cached at ${cacheFile}` : "Template catalog not cached yet (extension_templates will fetch it)"
8197
8988
  });
8989
+ if ("darwin" === process.platform) {
8990
+ const safariBinary = "/Applications/Safari.app/Contents/MacOS/Safari";
8991
+ const automation = await detectSafariAutomation(node_fs.existsSync(safariBinary) ? safariBinary : null);
8992
+ checks.push({
8993
+ check: "safari-agent",
8994
+ status: automation.mcp ? "pass" : "warn",
8995
+ detail: automation.mcp ? `Apple's Safari MCP server is available (${automation.safaridriver} --mcp)` : automation.safaridriver ? `${automation.safaridriver} has no --mcp flag, so Safari has no agent-readable window from this machine` : "No safaridriver found, so Safari has no agent-readable window from this machine",
8996
+ remediation: automation.mcp ? `Enable ${SAFARI_MCP_SETTING}, then add it beside this server: ${SAFARI_MCP_ADD_COMMAND}. It reads pages in an isolated automation window and has no extension-aware tool.` : "Install Safari 27 (Software Update) or Safari Technology Preview 247+ for Apple's Safari MCP server. A Safari dev session still builds, opens and guides the enable step without it."
8997
+ });
8998
+ }
8198
8999
  const healthy = checks.every((c)=>"fail" !== c.status);
8199
9000
  return envelope_envelope({
8200
9001
  ok: healthy,
@@ -8247,6 +9048,25 @@ function capabilityProbeChecks(parsed) {
8247
9048
  return value?.checks;
8248
9049
  }
8249
9050
  const CONTROL_OFF_BY_CHOICE = /\bwas not started with --allow-control\b/i;
9051
+ function pidIsAlive(pid) {
9052
+ try {
9053
+ process.kill(pid, 0);
9054
+ return true;
9055
+ } catch {
9056
+ return false;
9057
+ }
9058
+ }
9059
+ function reconcileRelaunchedBrowser(checks, contract) {
9060
+ const exitedLeg = checks.find((leg)=>"browser" === leg.check && "fail" === leg.status && /\bexited\b/i.test(String(leg.detail ?? "")));
9061
+ if (!exitedLeg) return false;
9062
+ const executorAnswered = checks.some((leg)=>("executor" === leg.check || "control-channel" === leg.check) && "pass" === leg.status);
9063
+ const browserAlive = "number" == typeof contract?.browserPid && pidIsAlive(contract.browserPid);
9064
+ if (!executorAnswered && !browserAlive) return false;
9065
+ exitedLeg.status = "warn";
9066
+ exitedLeg.detail = `${exitedLeg.detail ?? "browser exited"}. ${browserAlive ? `The browser pid the launcher recorded (${contract?.browserPid}) is alive` : "The executor answered a probe after that exit"}, so the session is live and the recorded exit was an earlier process: Firefox hands a fresh profile to a relaunched process and the first one exits 0.`;
9067
+ exitedLeg.remediation = "Nothing to do. If a later call finds the session unreachable, extension_stop and extension_dev again.";
9068
+ return true;
9069
+ }
8250
9070
  function controlOffByChoiceLeg(checks) {
8251
9071
  return checks.find((leg)=>"control-channel" === leg.check && "fail" === leg.status && "string" == typeof leg.detail && CONTROL_OFF_BY_CHOICE.test(leg.detail)) ?? null;
8252
9072
  }
@@ -8283,23 +9103,15 @@ async function doctor_handler(args) {
8283
9103
  if (!Array.isArray(probed)) throw new Error("not a check array");
8284
9104
  const checks = probed;
8285
9105
  const readOnlyLeg = controlOffByChoiceLeg(checks);
8286
- for (const check of checks){
8287
- if ("string" == typeof check.detail) check.detail = toMcpSpeak(check.detail);
8288
- if ("string" == typeof check.remediation) check.remediation = toMcpSpeak(check.remediation);
8289
- }
8290
- let healthy = 0 === code;
8291
- const contract = readContractForDiagnosis(projectPath, browser);
8292
- if (contract?.status === "error") {
8293
- healthy = false;
8294
- const browserExited = "browser_exited" === contract.code || void 0 !== contract.browserExitCode;
8295
- const detail = browserExited ? `The ${browser} browser for this session exited unexpectedly${null != contract.browserExitCode ? ` (exit code ${contract.browserExitCode})` : ""}; the extension may have been rejected or the browser crashed. The session cannot be driven.` : contract.errors && contract.errors.length ? contract.errors.join("; ") : contract.message || "The dev session recorded status: error in ready.json.";
8296
- checks.push({
8297
- check: "runtime-errors",
8298
- status: "fail",
8299
- detail: toMcpSpeak(detail),
8300
- remediation: browserExited ? "Read extension_logs and the session log for the rejection cause, call extension_stop to clean up, then relaunch." : "The build or extension load failed. Fix the reported error, let the dev server recompile, then re-run doctor."
8301
- });
8302
- } else {
9106
+ for (const check of checks){
9107
+ if ("string" == typeof check.detail) check.detail = toMcpSpeak(check.detail);
9108
+ if ("string" == typeof check.remediation) check.remediation = toMcpSpeak(check.remediation);
9109
+ }
9110
+ let healthy = 0 === code;
9111
+ const contract = readContractForDiagnosis(projectPath, browser);
9112
+ const relaunched = reconcileRelaunchedBrowser(checks, contract);
9113
+ if (relaunched) healthy = !checks.some((leg)=>"fail" === leg.status);
9114
+ if (contract?.status !== "error" || relaunched) {
8303
9115
  const errs = recentErrorLogs(projectPath, browser);
8304
9116
  if (errs.length) {
8305
9117
  healthy = false;
@@ -8310,6 +9122,16 @@ async function doctor_handler(args) {
8310
9122
  remediation: "The extension is throwing at runtime. Inspect with extension_logs. A chrome.* API called without its permission is a common cause: extension_manifest_validate catches a permission MISSING FROM permissions[], but it does not model host-permission scope (e.g. webRequest with no matching host_permissions) or gesture requirements (e.g. activeTab without a user gesture), so a valid:true there does not rule those out."
8311
9123
  });
8312
9124
  }
9125
+ } else {
9126
+ healthy = false;
9127
+ const browserExited = "browser_exited" === contract.code || void 0 !== contract.browserExitCode;
9128
+ const detail = browserExited ? `The ${browser} browser for this session exited unexpectedly${null != contract.browserExitCode ? ` (exit code ${contract.browserExitCode})` : ""}; the extension may have been rejected or the browser crashed. The session cannot be driven.` : contract.errors && contract.errors.length ? contract.errors.join("; ") : contract.message || "The dev session recorded status: error in ready.json.";
9129
+ checks.push({
9130
+ check: "runtime-errors",
9131
+ status: "fail",
9132
+ detail: toMcpSpeak(detail),
9133
+ remediation: browserExited ? "Read extension_logs and the session log for the rejection cause, call extension_stop to clean up, then relaunch." : "The build or extension load failed. Fix the reported error, let the dev server recompile, then re-run doctor."
9134
+ });
8313
9135
  }
8314
9136
  const engineVersion = projectEngineVersion(projectPath);
8315
9137
  if (engineVersion) {
@@ -8324,6 +9146,19 @@ async function doctor_handler(args) {
8324
9146
  } : {}
8325
9147
  });
8326
9148
  }
9149
+ if (WEBKIT_FAMILY.has(browser)) {
9150
+ const info = readWebDriverSession(projectPath, browser);
9151
+ const alive = info ? await new WebDriverClient(info).alive() : false;
9152
+ if (info && !alive) healthy = false;
9153
+ checks.push({
9154
+ check: "safari-window",
9155
+ status: info ? alive ? "pass" : "fail" : "skip",
9156
+ detail: info ? alive ? `Safari automation window recorded by the dev session (safaridriver on port ${info.port}, session ${info.sessionId})` : `ready.json records a Safari automation session on port ${info.port}, but it no longer answers: the window or the driver is gone` : "no safaridriver session recorded; page-world eval and open by url use the bridge, and everything else already does",
9157
+ ...info && !alive ? {
9158
+ remediation: "Stop and restart extension_dev --browser=safari; the session that recorded the window opens it again on the first package."
9159
+ } : {}
9160
+ });
9161
+ }
8327
9162
  const failures = checks.filter((leg)=>"fail" === leg.status);
8328
9163
  const readOnly = null !== readOnlyLeg && 1 === failures.length && failures[0] === readOnlyLeg;
8329
9164
  if (readOnly && readOnlyLeg) {
@@ -8740,6 +9575,16 @@ async function readFromStream(args, browser, limit) {
8740
9575
  async function logs_handler(args) {
8741
9576
  const { browser } = resolveSessionBrowser(args.projectPath, args.browser);
8742
9577
  const limit = args.limit && args.limit > 0 ? args.limit : DEFAULT_LIMIT;
9578
+ if (WEBKIT_FAMILY.has(browser) && !node_fs.existsSync(logsPath(args.projectPath, browser))) return envelope_envelope({
9579
+ ok: false,
9580
+ command: TOOL,
9581
+ status: "no-log-file",
9582
+ error: {
9583
+ code: "E_LOGS_MISSING",
9584
+ message: `No logs found at ${logsPath(args.projectPath, browser)}, and ${browser} has no CDP or RDP to read instead.`
9585
+ },
9586
+ hint: "Safari logs arrive through the extension's bridge to the dev server, with background and content contexts, once the extension is enabled and a dev session from an Extension.js that streams Safari logs (4.1.28 or newer) is running. Start extension_dev --browser=safari, enable the extension in Safari > Settings > Extensions, then read again. The page a content script changes is also readable with extension_eval (context: 'page') or extension_assert content-script-injected."
9587
+ });
8743
9588
  if (args.follow) return readFromStream(args, browser, limit);
8744
9589
  return readFromFile(args, browser, limit);
8745
9590
  }
@@ -8916,6 +9761,10 @@ function parseClauses(raw) {
8916
9761
  }
8917
9762
  const NO_SESSION_SETTLED_BY = "Start the session with extension_dev, confirm it with extension_wait, then assert again.";
8918
9763
  class Stage {
9764
+ webdriver() {
9765
+ const info = readWebDriverSession(this.projectPath, this.browser);
9766
+ return info ? new WebDriverClient(info) : null;
9767
+ }
8919
9768
  async port() {
8920
9769
  if (void 0 === this.cdpPort) {
8921
9770
  const resolved = await resolveCdpPort(this.projectPath, this.browser);
@@ -8983,6 +9832,7 @@ class Stage {
8983
9832
  assert_define_property(this, "browser", void 0);
8984
9833
  assert_define_property(this, "timeout", void 0);
8985
9834
  assert_define_property(this, "chromium", void 0);
9835
+ assert_define_property(this, "webkit", void 0);
8986
9836
  assert_define_property(this, "cdpPort", void 0);
8987
9837
  assert_define_property(this, "discovered", void 0);
8988
9838
  assert_define_property(this, "manifestRead", void 0);
@@ -8994,6 +9844,7 @@ class Stage {
8994
9844
  this.discovered = null;
8995
9845
  this.client = null;
8996
9846
  this.chromium = isChromiumFamily(browser);
9847
+ this.webkit = WEBKIT_FAMILY.has(browser);
8997
9848
  }
8998
9849
  }
8999
9850
  function truncate(value, max = 200) {
@@ -9125,12 +9976,77 @@ async function assertContentScriptInjected(clause, stage) {
9125
9976
  runId,
9126
9977
  coveringMatches: covering
9127
9978
  });
9979
+ if (stage.webkit) return assertContentScriptInjectedOnWebKit(clause, stage, {
9980
+ covering,
9981
+ patterns
9982
+ });
9128
9983
  return inconclusiveCheck(id, subject, covering.length > 0 ? `${covering.length} declared content_scripts match(es) cover ${clause.url} (${covering.join(", ")}), but nothing observable proves the script executed there: this platform cannot see into a content script's isolated world, and the content context logged nothing at that url in run ${runId || "(unnamed)"}.${stale ? ` ${stale}` : ""}` : `The built manifest (${read.file}) declares no content_scripts match covering ${clause.url}${patterns.length ? ` (declared: ${patterns.join(", ")})` : " and declares no content script at all"}. That is not proof of non-injection either: scripts registered at runtime with chrome.scripting.registerContentScripts are invisible to this reader.`, "Have the content script write one line, a console call or a dx.signal, and this check reads it from the log stream. To settle it now, read a marker the script sets with extension_eval (context: 'content', url: the page), which runs in the same isolated world the content script does.", {
9129
9984
  coveringMatches: covering,
9130
9985
  declaredMatches: patterns,
9131
9986
  runId
9132
9987
  });
9133
9988
  }
9989
+ async function assertBackgroundOnWebKit(clause, stage) {
9990
+ const id = BACKGROUND;
9991
+ const subject = clause.subject;
9992
+ const runId = readLogRunId(stage.projectPath, stage.browser);
9993
+ const stale = staleFileNote(stage.projectPath, stage.browser, runId);
9994
+ const lines = readLogEvents(stage.projectPath, stage.browser, {
9995
+ context: [
9996
+ "background"
9997
+ ]
9998
+ });
9999
+ if (lines.length > 0 && !stale) return passCheck(id, subject, `The background context wrote ${lines.length} log line(s) in run ${runId || "(unnamed)"} over the extension's bridge, and only a running background writes from that context.`, {
10000
+ lines: lines.length,
10001
+ runId
10002
+ });
10003
+ return inconclusiveCheck(id, subject, `${stage.browser} lists no background worker to this server, and the background context logged nothing in run ${runId || "(unnamed)"}${stale ? ` (${stale})` : ""}.`, "Have the background write one line on start (a console call), which reaches the dev session's log over the bridge once the extension is enabled; or open Web Inspector (Develop > Web Extension Background Content), attended.", {
10004
+ runId
10005
+ });
10006
+ }
10007
+ async function assertContentScriptInjectedOnWebKit(clause, stage, declared) {
10008
+ const id = CONTENT_SCRIPT;
10009
+ const subject = clause.subject;
10010
+ const client = stage.webdriver();
10011
+ if (!client) return inconclusiveCheck(id, subject, `The content context logged nothing at ${clause.url} in this run, and no safaridriver session is recorded for ${stage.browser}, so no page was read either.${declared.covering.length ? ` ${declared.covering.length} declared match(es) cover the url (${declared.covering.join(", ")}), which is not proof the script ran.` : ""}`, `Open ${clause.url} in Safari with the extension enabled, have the content script write one line (a console call), and assert again: the line reaches the dev session's log over the bridge. ${WEBDRIVER_SESSION_MISSING_HINT}`, {
10012
+ coveringMatches: declared.covering,
10013
+ declaredMatches: declared.patterns
10014
+ });
10015
+ let reading;
10016
+ try {
10017
+ const current = await client.currentUrl();
10018
+ if (!sameDocument(current, clause.url)) {
10019
+ await client.navigate(clause.url);
10020
+ await new Promise((resolve)=>setTimeout(resolve, 1200));
10021
+ }
10022
+ reading = await readExtensionRoots(client);
10023
+ } catch (error) {
10024
+ return inconclusiveCheck(id, subject, `The Safari automation window could not be read: ${error instanceof Error ? error.message : String(error)}.`, "Confirm the dev session still holds its window with extension_doctor, restart extension_dev --browser=safari if it ended, then assert again.", {
10025
+ coveringMatches: declared.covering,
10026
+ declaredMatches: declared.patterns
10027
+ });
10028
+ }
10029
+ const extensionId = readyExtensionId(stage.projectPath, stage.browser) ?? await stage.extensionId();
10030
+ if (!extensionId) return inconclusiveCheck(id, subject, `The Safari automation window shows ${reading.roots} extension root(s) at ${reading.url}, but this session recorded no extension id, so none of them can be attributed to this extension rather than another one.`, "Restart extension_dev --browser=safari on an Extension.js that stamps extensionId into ready.json, then assert again.", {
10031
+ roots: reading.roots,
10032
+ owners: reading.owners,
10033
+ url: reading.url
10034
+ });
10035
+ const owned = reading.owners.filter((owner)=>owner.length > 0 && owner.includes(extensionId));
10036
+ if (owned.length > 0) return passCheck(id, subject, `${owned.length} extension root(s) mounted by this extension's content script are in the DOM at ${reading.url}, read from the Safari automation window; only an injected content script mounts them.`, {
10037
+ roots: reading.roots,
10038
+ owners: owned,
10039
+ url: reading.url,
10040
+ coveringMatches: declared.covering
10041
+ });
10042
+ return inconclusiveCheck(id, subject, declared.covering.length > 0 ? `${declared.covering.length} declared content_scripts match(es) cover ${clause.url} (${declared.covering.join(", ")}), and the Safari automation window shows ${reading.roots} extension root(s) at ${reading.url}${reading.roots > 0 ? " none of which names this extension as owner" : ""}. A script that mounts no root leaves nothing this reader can see, and Safari carries no console feed over WebDriver to read a line instead.` : `The built manifest declares no content_scripts match covering ${clause.url}${declared.patterns.length ? ` (declared: ${declared.patterns.join(", ")})` : " and declares no content script at all"}, and the Safari automation window shows ${reading.roots} extension root(s) there.`, "Have the content script mount an element with data-extension-root (Extension.js stamps the owner on it), or set any DOM marker and read it with extension_eval (context: 'page', url: the page). A console line would need the WebDriver BiDi log domain, which this server does not speak yet.", {
10043
+ roots: reading.roots,
10044
+ owners: reading.owners,
10045
+ url: reading.url,
10046
+ coveringMatches: declared.covering,
10047
+ declaredMatches: declared.patterns
10048
+ });
10049
+ }
9134
10050
  function readStorageValue(value, key) {
9135
10051
  if (null == value) return {
9136
10052
  shape: "absent"
@@ -9244,6 +10160,7 @@ function assertConsoleErrorsEmpty(clause, stage) {
9244
10160
  });
9245
10161
  }
9246
10162
  async function evaluateClause(clause, stage) {
10163
+ if (stage.webkit && clause.assert === BACKGROUND) return assertBackgroundOnWebKit(clause, stage);
9247
10164
  switch(clause.assert){
9248
10165
  case BACKGROUND:
9249
10166
  return assertBackgroundWorker(clause, stage);
@@ -11170,6 +12087,32 @@ const inspect_schema_schema = {
11170
12087
  ]
11171
12088
  }
11172
12089
  };
12090
+ const EXTENSION_PAGE_CONTEXTS = [
12091
+ "popup",
12092
+ "options",
12093
+ "sidebar",
12094
+ "newtab",
12095
+ "history",
12096
+ "bookmarks"
12097
+ ];
12098
+ const EXTENSION_ORIGIN = /^(moz|chrome|safari-web)-extension:\/\//;
12099
+ function isExtensionUrl(url) {
12100
+ return "string" == typeof url && EXTENSION_ORIGIN.test(url);
12101
+ }
12102
+ function surfaceForExtensionUrl(projectPath, browser, url) {
12103
+ const bare = url.replace(EXTENSION_ORIGIN, "").replace(/^[^/]*\//, (m)=>EXTENSION_ORIGIN.test(url) ? "" : m).replace(/^\.?\//, "").replace(/[?#].*$/, "");
12104
+ if (!bare) return null;
12105
+ for (const context of EXTENSION_PAGE_CONTEXTS){
12106
+ const document = surfaceDocument(projectPath, browser, context);
12107
+ if (document) {
12108
+ if (bare === document || bare.endsWith(`/${document}`) || document.endsWith(`/${bare}`)) return {
12109
+ context,
12110
+ document
12111
+ };
12112
+ }
12113
+ }
12114
+ return null;
12115
+ }
11173
12116
  const inspect_gecko_TOOL = inspect_schema_schema.name;
11174
12117
  function buildBridgeInspectExpression(opts) {
11175
12118
  const parts = [
@@ -11287,7 +12230,22 @@ async function collectGeckoConsole(args, browser, urlFilter, result, notes) {
11287
12230
  }
11288
12231
  async function inspectViaBridge(args, browser, include, maxBytes) {
11289
12232
  const notes = [];
11290
- if (args.url) {
12233
+ const surface = args.url ? surfaceForExtensionUrl(args.projectPath, browser, args.url) : null;
12234
+ if (args.url && !surface && EXTENSION_ORIGIN.test(args.url)) {
12235
+ const declared = declaredSurfaces(args.projectPath, browser) ?? [];
12236
+ return envelope_envelope({
12237
+ ok: false,
12238
+ command: inspect_gecko_TOOL,
12239
+ status: "no-surface",
12240
+ error: {
12241
+ code: "E_NO_SURFACE_DOCUMENT",
12242
+ name: "NoSurfaceDocument",
12243
+ message: `${args.url} is a page inside the extension, and on ${browser} a page inside the extension is read through its own surface relay, but that path matches none of the surface documents the manifest declares${declared.length ? ` (${declared.join(", ")})` : ""}. Script injection cannot reach an extension page at all.`
12244
+ },
12245
+ hint: declared.length ? `Pass the declared document path (extension_open surface: "${declared[0]}" opens one), or read it with extension_dom_snapshot context: "${declared[0]}".` : "Declare the page as a surface in the manifest (action.default_popup, options_ui.page, sidebar_action.default_panel or chrome_url_overrides) and rebuild."
12246
+ });
12247
+ }
12248
+ if (args.url && !surface) {
11291
12249
  const listed = await listBridgeTabs(args.projectPath, browser, args.timeout, inspect_gecko_TOOL);
11292
12250
  if ("error" in listed) return listed.error;
11293
12251
  const already = listed.tabs.some((t)=>t.url.includes(args.url));
@@ -11314,8 +12272,8 @@ async function inspectViaBridge(args, browser, include, maxBytes) {
11314
12272
  expression,
11315
12273
  args.projectPath,
11316
12274
  "--context",
11317
- "page",
11318
- ...args.url ? [
12275
+ surface ? surface.context : "page",
12276
+ ...args.url && !surface ? [
11319
12277
  "--url",
11320
12278
  args.url
11321
12279
  ] : [],
@@ -11369,7 +12327,11 @@ async function inspectViaBridge(args, browser, include, maxBytes) {
11369
12327
  if (null === value) return raw;
11370
12328
  const result = {
11371
12329
  browser,
11372
- transport: "bridge"
12330
+ transport: "bridge",
12331
+ ...surface ? {
12332
+ surface: surface.context,
12333
+ document: surface.document
12334
+ } : {}
11373
12335
  };
11374
12336
  if (value.meta) {
11375
12337
  result.target = {
@@ -11845,9 +12807,72 @@ async function listGeckoExtensions(projectPath, browser) {
11845
12807
  });
11846
12808
  }
11847
12809
  }
12810
+ const RELAY_MARK = "__extensionDevRelay";
12811
+ function cloneHelper() {
12812
+ return `var clone = function (v) {
12813
+ if (v === undefined) return null;
12814
+ try {
12815
+ if (typeof structuredClone === "function") { structuredClone(v); return v; }
12816
+ return JSON.parse(JSON.stringify(v));
12817
+ } catch (e) { return String(v); }
12818
+ };`;
12819
+ }
12820
+ function relaySafeExpression(expression, token) {
12821
+ const src = JSON.stringify(expression);
12822
+ const key = JSON.stringify(token);
12823
+ return `(function () {
12824
+ ${cloneHelper()}
12825
+ var value = (0, eval)(${src});
12826
+ if (value && typeof value.then === "function") {
12827
+ var store = globalThis.${RELAY_MARK} = globalThis.${RELAY_MARK} || {};
12828
+ store[${key}] = { done: false };
12829
+ Promise.resolve(value).then(function (settled) {
12830
+ store[${key}] = { done: true, ok: true, value: clone(settled) };
12831
+ }, function (error) {
12832
+ store[${key}] = { done: true, ok: false, name: (error && error.name) || "EvalError", message: (error && error.message) || String(error) };
12833
+ });
12834
+ return { ${RELAY_MARK}: 1, done: false, token: ${key} };
12835
+ }
12836
+ return { ${RELAY_MARK}: 1, done: true, ok: true, value: clone(value) };
12837
+ })()`;
12838
+ }
12839
+ function relayPollExpression(token) {
12840
+ const key = JSON.stringify(token);
12841
+ return `(function () {
12842
+ var store = globalThis.${RELAY_MARK};
12843
+ var entry = store && store[${key}];
12844
+ if (!entry) return { ${RELAY_MARK}: 1, done: true, ok: false, name: "RelayLost", message: "the page reloaded or navigated before the expression settled, so its result is gone" };
12845
+ if (!entry.done) return { ${RELAY_MARK}: 1, done: false, token: ${key} };
12846
+ delete store[${key}];
12847
+ return { ${RELAY_MARK}: 1, done: true, ok: entry.ok, value: entry.value, name: entry.name, message: entry.message };
12848
+ })()`;
12849
+ }
12850
+ function readRelayFrame(value) {
12851
+ if (!value || "object" != typeof value || Array.isArray(value)) return null;
12852
+ const frame = value;
12853
+ if (1 !== frame[RELAY_MARK] || "boolean" != typeof frame.done) return null;
12854
+ return {
12855
+ done: frame.done,
12856
+ ..."boolean" == typeof frame.ok ? {
12857
+ ok: frame.ok
12858
+ } : {},
12859
+ ..."value" in frame ? {
12860
+ value: frame.value
12861
+ } : {},
12862
+ ..."string" == typeof frame.name ? {
12863
+ name: frame.name
12864
+ } : {},
12865
+ ..."string" == typeof frame.message ? {
12866
+ message: frame.message
12867
+ } : {},
12868
+ ..."string" == typeof frame.token ? {
12869
+ token: frame.token
12870
+ } : {}
12871
+ };
12872
+ }
11848
12873
  const eval_schema = {
11849
12874
  name: "extension_eval",
11850
- description: "Evaluate an expression in a running extension context. Start the session with allowEval:true (extension_dev), which writes a 0600 session token. Context defaults to 'background', except on a Chromium MV3 session (the default template) where it defaults to 'page', the active tab, because the MV3 service worker CSP blocks eval; pass context:'background' to target the worker anyway and get that explanation back. For content and page, pass `url` to pick the tab, or omit both `url` and `tab` for the active tab; a numeric `tab` only disambiguates. Extension surfaces (popup, options, sidebar, devtools) and override pages evaluate over the in-bundle relay and need no tab id, but must already be open: open one with extension_open first, because a closed one returns an explicit error. Call extension_dom_snapshot with listTabs:true to enumerate {tabId, url, title}.",
12875
+ description: "Evaluate an expression in a running extension context. Start the session with allowEval:true (extension_dev), which writes a 0600 session token. Context defaults to 'background', except on a Chromium MV3 session (the default template) where it defaults to 'page', the active tab, because the MV3 service worker CSP blocks eval; pass context:'background' to target the worker anyway and get that explanation back. For content and page, pass `url` to pick the tab, or omit both `url` and `tab` for the active tab; a numeric `tab` only disambiguates. Extension surfaces (popup, options, sidebar, devtools) and override pages (newtab, history, bookmarks) need no tab id but must already be open: open one with extension_open first, because a closed one returns an explicit error. On a Chromium MV3 session those pages, and context:'page' with a chrome-extension:// url, evaluate over CDP, the inspector path the extension page CSP does not govern; elsewhere they evaluate over the in-bundle relay. Call extension_dom_snapshot with listTabs:true to enumerate {tabId, url, title}.",
11851
12876
  inputSchema: {
11852
12877
  type: "object",
11853
12878
  properties: {
@@ -11889,15 +12914,8 @@ const eval_schema = {
11889
12914
  ]
11890
12915
  }
11891
12916
  };
11892
- function resolveDefaultEvalContext(projectPath, browser) {
11893
- if (!isChromiumFamily(browser)) return "background";
11894
- const candidates = [
11895
- node_path.join(projectPath, "dist", browser, "manifest.json"),
11896
- node_path.join(projectPath, "dist", "manifest.json"),
11897
- node_path.join(projectPath, "src", "manifest.json"),
11898
- node_path.join(projectPath, "manifest.json")
11899
- ];
11900
- for (const file of candidates){
12917
+ function chromiumManifestVersion(projectPath, browser) {
12918
+ for (const file of manifestCandidates(projectPath, browser)){
11901
12919
  let manifest;
11902
12920
  try {
11903
12921
  manifest = JSON.parse(node_fs.readFileSync(file, "utf8"));
@@ -11905,15 +12923,247 @@ function resolveDefaultEvalContext(projectPath, browser) {
11905
12923
  continue;
11906
12924
  }
11907
12925
  const version = manifest["chromium:manifest_version"] ?? manifest.manifest_version;
11908
- if (3 === version) return "page";
11909
- if (2 === version) break;
12926
+ if (3 === version) return 3;
12927
+ if (2 === version) return 2;
12928
+ }
12929
+ return null;
12930
+ }
12931
+ function resolveDefaultEvalContext(projectPath, browser) {
12932
+ if (!isChromiumFamily(browser)) return "background";
12933
+ return 3 === chromiumManifestVersion(projectPath, browser) ? "page" : "background";
12934
+ }
12935
+ function wantsExtensionPageOverCdp(projectPath, browser, context, url) {
12936
+ if (!isChromiumFamily(browser) || !context) return false;
12937
+ if ("page" === context) return "string" == typeof url && /^chrome-extension:\/\//.test(url);
12938
+ return EXTENSION_PAGE_CONTEXTS.includes(context) && 3 === chromiumManifestVersion(projectPath, browser);
12939
+ }
12940
+ async function evaluateOnChromiumExtensionPage(args, browser, context) {
12941
+ const resolved = await resolveCdpPort(args.projectPath, browser);
12942
+ if (!resolved) return envelope_envelope({
12943
+ ok: false,
12944
+ command: eval_schema.name,
12945
+ status: "no-session",
12946
+ error: {
12947
+ code: "E_NO_SESSION",
12948
+ name: "NoSession",
12949
+ message: `No active dev session / CDP port for ${browser}, and an extension page evaluates over CDP. Start extension_dev with allowEval: true and extension_wait for ready. ${CDP_PORT_MISSING_HINT}`
12950
+ }
12951
+ });
12952
+ let wanted;
12953
+ if ("page" === context) wanted = args.url;
12954
+ else {
12955
+ const doc = surfaceDocument(args.projectPath, browser, context);
12956
+ if (!doc) {
12957
+ const key = SURFACE_MANIFEST_KEYS[context] ?? context;
12958
+ return envelope_envelope({
12959
+ ok: false,
12960
+ command: eval_schema.name,
12961
+ status: "no-surface",
12962
+ error: {
12963
+ code: "E_NO_SURFACE_DOCUMENT",
12964
+ name: "NoSurfaceDocument",
12965
+ message: `This extension declares no ${context}: nothing in its manifest sets ${key}, so there is no ${context} page to evaluate in.`
12966
+ },
12967
+ hint: `To add one, set ${key} in the manifest and rebuild.`
12968
+ });
12969
+ }
12970
+ const extensionId = await resolveExtensionId(args.projectPath, browser);
12971
+ if (!extensionId) return envelope_envelope({
12972
+ ok: false,
12973
+ command: eval_schema.name,
12974
+ status: "no-extension-id",
12975
+ error: {
12976
+ code: "E_NO_EXTENSION_ID",
12977
+ name: "NoExtensionId",
12978
+ message: "Could not resolve the extension id from the live session's CDP targets."
12979
+ },
12980
+ hint: `Confirm the session is ready (extension_wait). ${CDP_PORT_MISSING_HINT}`
12981
+ });
12982
+ wanted = `chrome-extension://${extensionId}/${doc}`;
11910
12983
  }
11911
- return "background";
12984
+ const targets = await findExtensionPageTargets(resolved.port, wanted);
12985
+ if (0 === targets.length) return envelope_envelope({
12986
+ ok: false,
12987
+ command: eval_schema.name,
12988
+ status: "no-target",
12989
+ error: {
12990
+ code: "E_NO_TARGET",
12991
+ name: "NoTarget",
12992
+ message: `No open page at ${wanted}, so there is nothing to evaluate in. An extension page evaluates over CDP on its own target, which exists only while the page is open.`
12993
+ },
12994
+ hint: "page" === context ? "Open it first with extension_open (url: this address), then retry. extension_dom_snapshot with listTargets: true lists what is open." : `Open it first with extension_open surface: "${context}", then retry. extension_dom_snapshot with listTargets: true lists what is open.`
12995
+ });
12996
+ const target = targets[0];
12997
+ const outcome = await evaluateOnExtensionPage(resolved.port, target.targetId, args.expression);
12998
+ const others = targets.slice(1);
12999
+ const warnings = others.length ? [
13000
+ `${targets.length} open pages match ${wanted}; evaluated in target ${target.targetId} (${target.url}). The others: ${others.map((t)=>t.targetId).join(", ")}. Close the copies you do not mean, or navigate away from them.`
13001
+ ] : [];
13002
+ if (!outcome.ok) return envelope_envelope({
13003
+ ok: false,
13004
+ command: eval_schema.name,
13005
+ status: outcome.thrown ? "eval-failed" : "cdp-failed",
13006
+ error: {
13007
+ code: outcome.thrown ? "E_EVAL" : "E_CDP",
13008
+ name: outcome.thrown ? "EvalError" : "CdpError",
13009
+ message: outcome.message
13010
+ },
13011
+ warnings,
13012
+ hint: outcome.thrown ? `The expression threw inside ${target.url}. It ran over CDP in the page's main world with extension APIs available; a promise is awaited, so an async expression can be returned directly.` : "The session's debug port refused the call or the target went away. extension_doctor names which; a session that ended needs extension_dev again."
13013
+ });
13014
+ return envelope_envelope({
13015
+ ok: true,
13016
+ command: eval_schema.name,
13017
+ status: "evaluated",
13018
+ value: outcome.value,
13019
+ warnings,
13020
+ hint: `Evaluated over CDP in ${target.url} (target ${target.targetId}), the inspector path the extension page CSP does not govern. The result is serialized by value, so return plain data rather than DOM nodes; a promise is awaited before returning.`
13021
+ });
13022
+ }
13023
+ async function evaluateOnWebKitPage(args, browser, info) {
13024
+ const client = new WebDriverClient(info);
13025
+ if (args.url) {
13026
+ const current = await client.currentUrl().catch(()=>null);
13027
+ if (!current || !current.startsWith(args.url)) await client.navigate(args.url);
13028
+ }
13029
+ try {
13030
+ const value = await client.execute(`return (${args.expression});`);
13031
+ return envelope_envelope({
13032
+ ok: true,
13033
+ command: eval_schema.name,
13034
+ status: "evaluated",
13035
+ value: {
13036
+ context: "page",
13037
+ browser,
13038
+ url: await client.currentUrl().catch(()=>null),
13039
+ result: value
13040
+ },
13041
+ hint: "Evaluated in the Safari automation window's main world over the dev session's WebDriver connection."
13042
+ });
13043
+ } catch (error) {
13044
+ return envelope_envelope({
13045
+ ok: false,
13046
+ command: eval_schema.name,
13047
+ status: "eval-failed",
13048
+ error: {
13049
+ code: "E_EVAL",
13050
+ name: "EvalError",
13051
+ message: error instanceof Error ? error.message : String(error)
13052
+ },
13053
+ hint: "The expression threw, or the automation window is gone. extension_doctor names which; a session that ended needs extension_dev again."
13054
+ });
13055
+ }
13056
+ }
13057
+ const RELAY_POLL_MS = 300;
13058
+ const RELAY_DEFAULT_BUDGET_MS = 30000;
13059
+ function tryParseFrame(raw) {
13060
+ try {
13061
+ const parsed = JSON.parse(raw);
13062
+ return parsed && "object" == typeof parsed ? parsed : null;
13063
+ } catch {
13064
+ return null;
13065
+ }
13066
+ }
13067
+ async function evaluateThroughRelay(args, browser, context) {
13068
+ const token = node_crypto.randomUUID();
13069
+ const budgetMs = args.timeout ?? RELAY_DEFAULT_BUDGET_MS;
13070
+ const deadline = Date.now() + budgetMs;
13071
+ const run = (expression)=>runActVerb([
13072
+ "eval",
13073
+ ...commonFlags({
13074
+ ...args,
13075
+ context,
13076
+ browser
13077
+ }),
13078
+ "--",
13079
+ expression,
13080
+ args.projectPath
13081
+ ], args.projectPath, args.timeout, eval_schema.name);
13082
+ let raw = await run(relaySafeExpression(args.expression, token));
13083
+ let parsed = tryParseFrame(raw);
13084
+ if (!parsed || true !== parsed.ok) return raw;
13085
+ let frame = readRelayFrame(parsed.value);
13086
+ if (!frame) return raw;
13087
+ let polls = 0;
13088
+ while(!frame.done){
13089
+ if (Date.now() >= deadline) return envelope_envelope({
13090
+ ok: false,
13091
+ command: eval_schema.name,
13092
+ status: "eval-pending",
13093
+ error: {
13094
+ code: "E_WAIT_TIMEOUT",
13095
+ name: "EvalPending",
13096
+ message: `The expression returned a promise that had not settled after ${budgetMs} ms; it is still running in the ${context} page.`
13097
+ },
13098
+ value: {
13099
+ context,
13100
+ token,
13101
+ polls
13102
+ },
13103
+ hint: `Its outcome lands in globalThis.${RELAY_MARK}[${JSON.stringify(token)}] inside the ${context} page when it settles ({done, ok, value}): read it with extension_eval in the same context, or pass a larger timeout to wait here.`
13104
+ });
13105
+ await new Promise((r)=>setTimeout(r, RELAY_POLL_MS));
13106
+ polls += 1;
13107
+ raw = await run(relayPollExpression(token));
13108
+ parsed = tryParseFrame(raw);
13109
+ if (!parsed || true !== parsed.ok) return raw;
13110
+ const next = readRelayFrame(parsed.value);
13111
+ if (!next) return raw;
13112
+ frame = next;
13113
+ }
13114
+ if (false === frame.ok) return envelope_envelope({
13115
+ ok: false,
13116
+ command: eval_schema.name,
13117
+ status: "eval-failed",
13118
+ error: {
13119
+ code: "E_EVAL",
13120
+ name: frame.name || "EvalError",
13121
+ message: frame.message || "the expression rejected"
13122
+ },
13123
+ hint: `The expression threw, or the promise it returned rejected, inside the ${context} page.`
13124
+ });
13125
+ parsed.value = void 0 === frame.value ? null : frame.value;
13126
+ if (polls > 0) parsed.hint = `The expression returned a promise; the ${context} page settled it and this call polled ${polls} time${1 === polls ? "" : "s"} for the result. ` + ("string" == typeof parsed.hint ? parsed.hint : "");
13127
+ return actFrameJson(parsed);
11912
13128
  }
11913
13129
  async function eval_handler(args) {
11914
13130
  const { browser } = resolveSessionBrowser(args.projectPath, args.browser);
13131
+ if (WEBKIT_FAMILY.has(browser) && "page" === args.context) {
13132
+ const info = readWebDriverSession(args.projectPath, browser);
13133
+ if (info) return evaluateOnWebKitPage(args, browser, info);
13134
+ }
11915
13135
  const defaulted = !args.context && "page" === resolveDefaultEvalContext(args.projectPath, browser);
11916
13136
  const context = defaulted ? "page" : args.context;
13137
+ if (wantsExtensionPageOverCdp(args.projectPath, browser, context, args.url)) return evaluateOnChromiumExtensionPage(args, browser, context);
13138
+ if ("page" === context && isExtensionUrl(args.url)) {
13139
+ const surface = surfaceForExtensionUrl(args.projectPath, browser, args.url);
13140
+ if (surface) {
13141
+ const raw = await evaluateThroughRelay({
13142
+ ...args,
13143
+ url: void 0,
13144
+ tab: void 0
13145
+ }, browser, surface.context);
13146
+ const parsed = tryParseFrame(raw);
13147
+ if (parsed) {
13148
+ addWarning(parsed, `${args.url} is the extension's own ${surface.context} document (${surface.document}), which script injection cannot reach on any engine, so this evaluated through the ${surface.context} surface relay. Pass context: "${surface.context}" directly next time.`);
13149
+ return actFrameJson(parsed);
13150
+ }
13151
+ return raw;
13152
+ }
13153
+ const declared = declaredSurfaces(args.projectPath, browser) ?? [];
13154
+ return envelope_envelope({
13155
+ ok: false,
13156
+ command: eval_schema.name,
13157
+ status: "no-surface",
13158
+ error: {
13159
+ code: "E_NO_SURFACE_DOCUMENT",
13160
+ name: "NoSurfaceDocument",
13161
+ message: `${args.url} is a page inside the extension, which script injection cannot reach, and it matches none of the surface documents the manifest declares${declared.length ? ` (${declared.join(", ")})` : ""}.`
13162
+ },
13163
+ hint: declared.length ? `Evaluate in a declared surface with context: "${declared[0]}" (open it first with extension_open), or read a web page by url.` : "Declare the page as a surface in the manifest (action.default_popup, options_ui.page, sidebar_action.default_panel or chrome_url_overrides) and rebuild."
13164
+ });
13165
+ }
13166
+ if (context && EXTENSION_PAGE_CONTEXTS.includes(context)) return evaluateThroughRelay(args, browser, context);
11917
13167
  const raw = await runActVerb([
11918
13168
  "eval",
11919
13169
  ...commonFlags({
@@ -12075,24 +13325,6 @@ async function reload_handler(args) {
12075
13325
  })
12076
13326
  ], args.projectPath, args.timeout, reload_schema.name);
12077
13327
  }
12078
- const TARGET_ID_NOTE = "targetId is a CDP target id, NOT a chrome.tabs id: do not pass it as `tab`. Target a tab with `tabUrl` (URL substring) or `url`; if you need a numeric tab id, call extension_dom_snapshot with listTabs: true.";
12079
- function filterPageTargets(raw) {
12080
- return raw.filter((t)=>"page" === t.type && !String(t.url ?? "").startsWith("devtools://")).map((t)=>({
12081
- targetId: String(t.id),
12082
- type: String(t.type),
12083
- url: String(t.url ?? ""),
12084
- title: String(t.title ?? "")
12085
- }));
12086
- }
12087
- async function listPageTargets(port) {
12088
- return filterPageTargets(await CDPClient.discoverTargets(port));
12089
- }
12090
- function matchTargetsByUrl(targets, needle) {
12091
- const wanted = needle.toLowerCase();
12092
- const byUrl = targets.filter((t)=>t.url.toLowerCase().includes(wanted));
12093
- if (byUrl.length > 0) return byUrl;
12094
- return targets.filter((t)=>t.title.toLowerCase().includes(wanted));
12095
- }
12096
13328
  const RDP_ACTOR_NOTE = "actor is an RDP tab descriptor actor id, NOT a chrome.tabs id: do not pass it as `tab`. Target a tab with `tabUrl` (URL substring) or `url`; if you need a numeric tab id, call extension_dom_snapshot with listTabs: true.";
12097
13329
  const dom_snapshot_schema = {
12098
13330
  name: "extension_dom_snapshot",
@@ -12542,6 +13774,11 @@ async function publish_handler(args) {
12542
13774
  }
12543
13775
  }
12544
13776
  }
13777
+ data.allowance = spendNarration({
13778
+ what: "This publish",
13779
+ body: data,
13780
+ api: args.api
13781
+ });
12545
13782
  return envelope_envelope({
12546
13783
  ok: true,
12547
13784
  command: "extension_publish",
@@ -12726,6 +13963,11 @@ async function release_promote_handler(args) {
12726
13963
  const promotedRef = resolveProjectRef();
12727
13964
  const publicChannelUrl = userlandProjectUrl(promotedRef, UserlandProjectPage.channel(channel), args.api);
12728
13965
  const publicBuildUrl = userlandProjectUrl(promotedRef, UserlandProjectPage.build(buildId), args.api);
13966
+ const allowance = spendNarration({
13967
+ what: "This promote",
13968
+ body: data,
13969
+ api: args.api
13970
+ });
12729
13971
  const enriched = data && "object" == typeof data && !Array.isArray(data) ? {
12730
13972
  ...data,
12731
13973
  ...publicChannelUrl ? {
@@ -12733,8 +13975,12 @@ async function release_promote_handler(args) {
12733
13975
  } : {},
12734
13976
  ...publicBuildUrl ? {
12735
13977
  publicBuildUrl
12736
- } : {}
12737
- } : data;
13978
+ } : {},
13979
+ allowance
13980
+ } : {
13981
+ platform: data,
13982
+ allowance
13983
+ };
12738
13984
  return envelope_envelope({
12739
13985
  ok: true,
12740
13986
  command: "extension_release_promote",
@@ -13457,7 +14703,14 @@ async function submit_handler(args) {
13457
14703
  if ("string" == typeof data?.message) result.platformMessage = data.message;
13458
14704
  message = summaryParts.join(" ");
13459
14705
  }
13460
- 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.";
14706
+ if (!dryRun) {
14707
+ statusNote = "Track this submission with extension_release_status: it reads the recorded outcome, per-store credential health, and review state from the public registry.";
14708
+ if (platformOk) result.allowance = spendNarration({
14709
+ what: "This submission",
14710
+ body: data,
14711
+ api: args.api
14712
+ });
14713
+ }
13461
14714
  return envelope_envelope({
13462
14715
  ok,
13463
14716
  command: "extension_submit",
@@ -13599,6 +14852,12 @@ async function wait_handler(args) {
13599
14852
  pid: contract.pid,
13600
14853
  distPath: contract.distPath,
13601
14854
  manifestPath: contract.manifestPath,
14855
+ ..."number" == typeof contract.browserPid ? {
14856
+ browserPid: contract.browserPid
14857
+ } : {},
14858
+ ..."string" == typeof contract.profilePath && contract.profilePath ? {
14859
+ profilePath: contract.profilePath
14860
+ } : {},
13602
14861
  compiledAt: contract.compiledAt,
13603
14862
  startedAt: contract.startedAt,
13604
14863
  budgetMs,
@@ -14277,7 +15536,7 @@ async function auth_handler(args) {
14277
15536
  });
14278
15537
  return readIdentity();
14279
15538
  }
14280
- const execFileAsync = promisify(execFile);
15539
+ const detect_browsers_execFileAsync = promisify(execFile);
14281
15540
  const ALL_BROWSERS = [
14282
15541
  "chrome",
14283
15542
  "chromium",
@@ -14455,44 +15714,58 @@ function resolveCacheRoot() {
14455
15714
  if (home) return node_path.join(home, ".cache", "extension.js", "browsers");
14456
15715
  return node_path.resolve(process.cwd(), ".cache", "extension.js", "browsers");
14457
15716
  }
14458
- function findManagedBinary(browser) {
14459
- const browserDir = node_path.join(resolveCacheRoot(), browser);
15717
+ const MANAGED_SEARCH_DEPTH = 8;
15718
+ const MANAGED_EXEC_NAMES = {
15719
+ chrome: [
15720
+ "chrome",
15721
+ "chrome.exe",
15722
+ "Google Chrome for Testing"
15723
+ ],
15724
+ chromium: [
15725
+ "chrome",
15726
+ "chromium",
15727
+ "chrome.exe",
15728
+ "chromium.exe",
15729
+ "Chromium"
15730
+ ],
15731
+ edge: [
15732
+ "msedge",
15733
+ "msedge.exe",
15734
+ "microsoft-edge",
15735
+ "Microsoft Edge"
15736
+ ],
15737
+ firefox: [
15738
+ "firefox",
15739
+ "firefox.exe",
15740
+ "firefox-bin"
15741
+ ]
15742
+ };
15743
+ function executableInsideBundle(bundle, names) {
15744
+ const macos = node_path.join(bundle, "Contents", "MacOS");
15745
+ try {
15746
+ for (const entry of node_fs.readdirSync(macos, {
15747
+ withFileTypes: true
15748
+ }))if (entry.isFile() && names.includes(entry.name)) return node_path.join(macos, entry.name);
15749
+ } catch {}
15750
+ return null;
15751
+ }
15752
+ function findManagedBinary(browser, cacheRoot = resolveCacheRoot()) {
15753
+ const browserDir = node_path.join(cacheRoot, browser);
14460
15754
  if (!node_fs.existsSync(browserDir)) return null;
14461
- const execNames = {
14462
- chrome: [
14463
- "chrome",
14464
- "chrome.exe",
14465
- "Google Chrome for Testing"
14466
- ],
14467
- chromium: [
14468
- "chrome",
14469
- "chromium",
14470
- "chrome.exe",
14471
- "chromium.exe",
14472
- "Chromium.app"
14473
- ],
14474
- edge: [
14475
- "msedge",
14476
- "msedge.exe",
14477
- "microsoft-edge",
14478
- "Microsoft Edge"
14479
- ],
14480
- firefox: [
14481
- "firefox",
14482
- "firefox.exe",
14483
- "Firefox.app"
14484
- ]
14485
- };
14486
- const names = execNames[browser] ?? [];
15755
+ const names = MANAGED_EXEC_NAMES[browser] ?? [];
14487
15756
  function search(dir, depth) {
14488
- if (depth > 4) return null;
15757
+ if (depth > MANAGED_SEARCH_DEPTH) return null;
14489
15758
  try {
14490
15759
  for (const entry of node_fs.readdirSync(dir, {
14491
15760
  withFileTypes: true
14492
15761
  })){
14493
15762
  const full = node_path.join(dir, entry.name);
14494
15763
  if (entry.isFile() && names.includes(entry.name)) return full;
14495
- if (entry.isDirectory() && depth < 4) {
15764
+ if (entry.isDirectory() && entry.name.endsWith(".app")) {
15765
+ const inside = executableInsideBundle(full, names);
15766
+ if (inside) return inside;
15767
+ }
15768
+ if (entry.isDirectory() && depth < MANAGED_SEARCH_DEPTH) {
14496
15769
  const found = search(full, depth + 1);
14497
15770
  if (found) return found;
14498
15771
  }
@@ -14511,7 +15784,7 @@ function findSystemBinary(browser) {
14511
15784
  async function getVersion(binaryPath, browser) {
14512
15785
  try {
14513
15786
  const flag = "firefox" === browser ? "--version" : "--version";
14514
- const { stdout } = await execFileAsync(binaryPath, [
15787
+ const { stdout } = await detect_browsers_execFileAsync(binaryPath, [
14515
15788
  flag
14516
15789
  ], {
14517
15790
  timeout: 5000,
@@ -14543,12 +15816,16 @@ async function detectBrowsers(browsers) {
14543
15816
  const isWebkit = WEBKIT_FAMILY.has(browser);
14544
15817
  let binaryPath = findManagedBinary(browser);
14545
15818
  let source = "managed";
15819
+ const systemBinaryPath = findSystemBinary(browser);
14546
15820
  if (!binaryPath) {
14547
- binaryPath = findSystemBinary(browser);
15821
+ binaryPath = systemBinaryPath;
14548
15822
  source = binaryPath ? "system" : "not_found";
14549
15823
  }
14550
15824
  let version = null;
14551
15825
  if (binaryPath && !isWebkit) version = await getVersion(binaryPath, browser);
15826
+ else if (binaryPath && isWebkit) version = await readSafariVersion(binaryPath);
15827
+ const automation = isWebkit && binaryPath ? await detectSafariAutomation(binaryPath) : null;
15828
+ const shadowedSystem = "managed" === source && systemBinaryPath && systemBinaryPath !== binaryPath ? systemBinaryPath : null;
14552
15829
  detected.push({
14553
15830
  browser,
14554
15831
  binaryPath,
@@ -14556,11 +15833,20 @@ async function detectBrowsers(browsers) {
14556
15833
  engine: isWebkit ? "webkit" : isGecko ? "gecko" : "chromium",
14557
15834
  version,
14558
15835
  cdpSupport: !isGecko && !isWebkit,
14559
- rdpSupport: isGecko
15836
+ rdpSupport: isGecko,
15837
+ ...automation ? {
15838
+ automation
15839
+ } : {},
15840
+ ...shadowedSystem ? {
15841
+ systemBinaryPath: shadowedSystem,
15842
+ devLaunches: `extension_dev launches the managed binary above, not the system install at ${shadowedSystem}; pass ${isGecko ? "geckoBinary" : "chromiumBinary"} to extension_dev to use the system one.`
15843
+ } : {}
14560
15844
  });
14561
15845
  }
14562
15846
  const available = detected.filter((d)=>"not_found" !== d.source);
14563
15847
  const missing = detected.filter((d)=>"not_found" === d.source);
15848
+ const safari = detected.find((d)=>d.automation);
15849
+ const safariHint = safari?.automation ? ` ${safariAutomationHint(safari.automation)}` : "";
14564
15850
  return envelope_envelope({
14565
15851
  ok: true,
14566
15852
  command: "extension_browsers",
@@ -14573,7 +15859,7 @@ async function detectBrowsers(browsers) {
14573
15859
  missing: missing.map((d)=>d.browser)
14574
15860
  }
14575
15861
  },
14576
- hint: missing.length ? `Missing browser(s): ${missing.map((d)=>d.browser).join(", ")}.${missing.some((d)=>MANAGED_INSTALLABLE.has(d.browser)) ? ` Use extension_browsers with action: "install" to install ${missing.filter((d)=>MANAGED_INSTALLABLE.has(d.browser)).map((d)=>d.browser).join(", ")}.` : ""}` : "All requested browsers are available."
15862
+ hint: missing.length ? `Missing browser(s): ${missing.map((d)=>d.browser).join(", ")}.${missing.some((d)=>MANAGED_INSTALLABLE.has(d.browser)) ? ` Use extension_browsers with action: "install" to install ${missing.filter((d)=>MANAGED_INSTALLABLE.has(d.browser)).map((d)=>d.browser).join(", ")}.` : ""}${safariHint}` : `All requested browsers are available.${safariHint}`
14577
15863
  });
14578
15864
  }
14579
15865
  const BROWSER_NAMES = [
@@ -14988,15 +16274,16 @@ function toolResultFrame(result) {
14988
16274
  }
14989
16275
  const toolMap = new Map();
14990
16276
  for (const tool of tools)toolMap.set(tool.schema.name, tool);
14991
- async function startServer() {
14992
- installCarrierExitCleanup();
16277
+ const SERVER_INSTRUCTIONS = "extension-dev runs, inspects, drives, builds and publishes browser extensions (Chrome, Edge, Firefox, Safari and the other Chromium and Gecko browsers) through Extension.js and extension.dev.\nWhen the ask is to run, start, wait for, watch, inspect, drive, test, debug or build a browser extension, search this server first and use its tools: extension_dev starts the dev session (allowEval: true also turns on control), extension_wait blocks until it is ready, extension_logs streams its console, extension_open opens a surface or a url, extension_dom_snapshot and extension_inspect read a live page, extension_eval runs code in a context, extension_build makes a store-ready bundle, extension_stop ends the session.\nThese replace hand-rolled ps, curl, remote-debugging-port lookups and CDP or Playwright scripts: the server already holds the session's debug port, the extension id and the session token.\nEvery tool answers one JSON envelope {ok, status, value, error, hint, warnings}; read hint and warnings before choosing the next call, and treat ok: false as the answer, not a transport error.";
16278
+ function createServer() {
14993
16279
  const server = new Server({
14994
16280
  name: "extension-dev",
14995
16281
  version: package_namespaceObject.rE
14996
16282
  }, {
14997
16283
  capabilities: {
14998
16284
  tools: {}
14999
- }
16285
+ },
16286
+ instructions: SERVER_INSTRUCTIONS
15000
16287
  });
15001
16288
  server.setRequestHandler(ListToolsRequestSchema, async ()=>({
15002
16289
  tools: tools.map((t)=>({
@@ -15063,6 +16350,11 @@ async function startServer() {
15063
16350
  };
15064
16351
  }
15065
16352
  });
16353
+ return server;
16354
+ }
16355
+ async function startServer() {
16356
+ installCarrierExitCleanup();
16357
+ const server = createServer();
15066
16358
  const transport = new StdioServerTransport();
15067
16359
  await server.connect(transport);
15068
16360
  }