@atlassian/chippy 0.0.1-beta.1 → 0.0.1-beta.2

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.
Files changed (54) hide show
  1. package/README.md +142 -54
  2. package/bin/chippy.mjs +53 -95
  3. package/bin/pack.mjs +20 -0
  4. package/bin/skill-dir.mjs +15 -0
  5. package/lib/avalanche.js +54 -23
  6. package/lib/avalanche.js.map +2 -2
  7. package/lib/bootstrap.js +20 -1
  8. package/lib/bootstrap.js.map +3 -3
  9. package/lib/extension/avalanche.es.js +173 -34
  10. package/lib/extension/avalanche.es.js.map +1 -1
  11. package/lib/extension/chippy-page-bridge.js +164 -44
  12. package/lib/mcp-skill/README.md +2 -4
  13. package/lib/mcp-skill/SKILL.md +21 -16
  14. package/lib/mcp-skill/extension/armed-reload-startup.js +83 -0
  15. package/lib/mcp-skill/extension/avalanche.es.js +173 -34
  16. package/lib/mcp-skill/extension/avalanche.es.js.map +1 -1
  17. package/lib/mcp-skill/extension/avalanche.iife.js +173 -34
  18. package/lib/mcp-skill/extension/avalanche.iife.js.map +1 -1
  19. package/lib/mcp-skill/extension/background.js +2 -0
  20. package/lib/mcp-skill/extension/chippy-page-bridge.js +164 -44
  21. package/lib/mcp-skill/extension/content-script.js +23 -0
  22. package/lib/mcp-skill/extension/devtools-hook-shim.js +18 -1
  23. package/lib/mcp-skill/extension/early-bootstrap.js +3 -1
  24. package/lib/mcp-skill/extension/manifest.json +1 -1
  25. package/lib/mcp-skill/extension/panel-settings.js +2 -20
  26. package/lib/mcp-skill/extension/startup-handoff.js +30 -0
  27. package/lib/mcp-skill/references/README.md +2 -2
  28. package/lib/mcp-skill/references/avalanche-detector.md +3 -3
  29. package/lib/mcp-skill/references/browser-runtime-api.md +4 -4
  30. package/lib/mcp-skill/references/command-output-formats.md +12 -2
  31. package/lib/mcp-skill/references/command-surface.md +6 -6
  32. package/lib/mcp-skill/references/mode-a-injection.md +43 -9
  33. package/lib/mcp-skill/references/playwright-cli-setup.md +5 -5
  34. package/lib/mcp-skill/references/playwright-probes.md +1 -1
  35. package/lib/mcp-skill/references/recipes.txt +1 -1
  36. package/lib/mcp-skill/references/stability-render.md +4 -4
  37. package/lib/mcp-skill/references/test-mode-playbook.md +2 -1
  38. package/lib/mcp-skill/references/troubleshooting.md +6 -1
  39. package/lib/mcp-skill/scripts/chippy-helper-server.js +116 -58
  40. package/lib/mcp-skill/scripts/chippy.sh +41 -7
  41. package/lib/mcp-skill/scripts/helper-access.js +20 -6
  42. package/lib/mcp-skill/scripts/helper-proxy.js +87 -0
  43. package/lib/mcp-skill/scripts/page-socket-trust.js +81 -0
  44. package/lib/plugin.js.map +2 -2
  45. package/lib/storybook/connection-fallback.jsx +1 -1
  46. package/lib/storybook/manager-owner.js +42 -0
  47. package/lib/storybook/manager.jsx +22 -0
  48. package/lib/storybook/preview.js +53 -18
  49. package/lib/storybook/setup.js +15 -4
  50. package/lib/types/chippy-bridge/fiber-source.d.ts +14 -3
  51. package/lib/types/chippy-bridge/react-renderer.d.ts +30 -5
  52. package/lib/types/chippy-bridge/recorded-source.d.ts +44 -0
  53. package/lib/types/debug-render/core.d.ts +6 -0
  54. package/package.json +3 -1
@@ -11,6 +11,8 @@ const {
11
11
  } = require('../browser-scripts/playwright-session');
12
12
  const { createProbeAnnotationChannel } = require('./probe-annotation-channel');
13
13
  const helperAccess = require('./helper-access');
14
+ const pageSocketTrust = require('./page-socket-trust');
15
+ const helperProxy = require('./helper-proxy');
14
16
 
15
17
  // ─── Config ───────────────────────────────────────────────────────────────────
16
18
 
@@ -74,6 +76,21 @@ function waitForPendingResult(pendingMap, requestId, timeoutMs, timeoutError) {
74
76
  });
75
77
  }
76
78
 
79
+ // The page answered, but without the Avalanche runtime it cannot run the
80
+ // command. Report that as an unavailable runtime instead of an empty result.
81
+ const BRIDGE_MODULE_MISSING_HINT = 'The page has the Chippy bridge but not the Avalanche runtime (avalanche.iife.js). '
82
+ + 'Load it after inject.js, import /__chippy/inject.mjs instead, or reload a page managed by the extension or proxy.';
83
+
84
+ function pageResultStatus(result) {
85
+ if (!result.ok) return 504;
86
+ return result.result && result.result.code === 'bridge-module-missing' ? 503 : 200;
87
+ }
88
+
89
+ function withPageResultHint(result) {
90
+ if (pageResultStatus(result) !== 503) return result;
91
+ return { ...result, ok: false, error: result.result.error, hint: BRIDGE_MODULE_MISSING_HINT };
92
+ }
93
+
77
94
  function resolvePendingResult(pendingMap, requestId, result) {
78
95
  const pending = pendingMap.get(requestId);
79
96
  if (!pending) return false;
@@ -138,10 +155,11 @@ async function requestProbeCommand(clientId, command, timeoutMs = 10_000) {
138
155
  Math.max(100, Number(timeoutMs || 10_000)),
139
156
  'Timeout waiting for probe command result'
140
157
  );
158
+ const status = pageResultStatus(result);
141
159
  return {
142
- ok: result.ok,
143
- code: result.ok ? 200 : 504,
144
- payload: { ...result, targetClientId: delivered.clientId, resolvedFrom: delivered.resolvedFrom, requestId },
160
+ ok: status === 200,
161
+ code: status,
162
+ payload: { ...withPageResultHint(result), targetClientId: delivered.clientId, resolvedFrom: delivered.resolvedFrom, requestId },
145
163
  };
146
164
  }
147
165
 
@@ -262,7 +280,7 @@ async function handoffToPlaywright({ clientId, url, probeIds, browserName, headl
262
280
  }
263
281
 
264
282
  // When active, non-Chippy requests are forwarded to targetPort (proxy mode).
265
- const proxyState = { active: false, targetPort: null, originalUrl: null, startedAt: null };
283
+ const proxyState = { active: false, targetPort: null, originalUrl: null, target: null, startedAt: null };
266
284
 
267
285
  // ─── Proxy HTML injection ─────────────────────────────────────────────────────
268
286
 
@@ -298,15 +316,21 @@ function buildProxyInlineBundle() {
298
316
  ].join('\n');
299
317
  }
300
318
 
319
+ // Without the extension, the proxy does its job: the DevTools hook shim and the
320
+ // Avalanche runtime load before page code, and the page bridge connects last.
321
+ // startup-handoff.js turns an armed reload's stored arm into the startup config
322
+ // before Avalanche evaluates, so the detector installs before React commits.
323
+ const PROXY_EARLY_SCRIPTS = [
324
+ `${CHIPPY_PREFIX}/devtools-hook-shim.js`,
325
+ `${CHIPPY_PREFIX}/startup-handoff.js`,
326
+ `${CHIPPY_PREFIX}/avalanche.iife.js`,
327
+ ];
328
+
301
329
  function injectIntoHtmlDocument(html) {
302
- const bundle = buildProxyInlineBundle();
303
- const lower = html.toLowerCase();
304
- // Insert just before </body> or </html>, whichever comes last
305
- for (const tag of ['</body>', '</html>']) {
306
- const i = lower.lastIndexOf(tag);
307
- if (i >= 0) return `${html.slice(0, i)}${bundle}\n${html.slice(i)}`;
308
- }
309
- return `${html}\n${bundle}`;
330
+ return helperProxy.injectProxyDocument(html, {
331
+ earlyScripts: PROXY_EARLY_SCRIPTS,
332
+ bridgeBundle: buildProxyInlineBundle(),
333
+ });
310
334
  }
311
335
 
312
336
  function stripHopHeaders(headers, bodyBuffer, stripCsp = false) {
@@ -384,6 +408,15 @@ const CORS_HEADERS = {
384
408
  'Cache-Control': 'no-store',
385
409
  };
386
410
 
411
+ // The detector's canonical modes. An unknown string would reach
412
+ // createAvalancheDetector and leave its mode state unset.
413
+ const DETECTION_MODES = ['sources', 'full', 'stats', 'amplifiers'];
414
+ function detectionModeError(options) {
415
+ const mode = options && typeof options === 'object' ? options.detectionMode : undefined;
416
+ if (mode === undefined || DETECTION_MODES.includes(mode)) return null;
417
+ return `detectionMode must be one of ${DETECTION_MODES.join(', ')} (got ${JSON.stringify(mode)})`;
418
+ }
419
+
387
420
  function json(res, code, payload) {
388
421
  res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8', ...CORS_HEADERS });
389
422
  res.end(JSON.stringify(payload, null, 2));
@@ -633,20 +666,26 @@ function isChippyPath(p) { return p === CHIPPY_PREFIX || p.startsWith(`${CHIPPY_
633
666
 
634
667
  function getProxySummary() {
635
668
  if (!proxyState.active) return null;
636
- return { active: true, targetPort: proxyState.targetPort, originalUrl: proxyState.originalUrl, injectTarget: getProxyInjectTarget(), startedAt: proxyState.startedAt };
669
+ return {
670
+ active: true,
671
+ targetPort: proxyState.targetPort,
672
+ targetHost: proxyState.target?.hostHeader || null,
673
+ originalUrl: proxyState.originalUrl,
674
+ injectTarget: getProxyInjectTarget(),
675
+ startedAt: proxyState.startedAt,
676
+ };
637
677
  }
638
678
 
639
679
  function startProxy(targetPort, originalUrl) {
640
- const p = Number(targetPort);
641
- if (!Number.isFinite(p) || p < 1 || p > 65535) throw new Error(`Invalid target port: ${targetPort}`);
642
- Object.assign(proxyState, { active: true, targetPort: p, originalUrl: originalUrl || null, startedAt: nowIso() });
643
- console.log(`[proxy] enabled -> port=${p}${originalUrl ? ` url=${originalUrl}` : ''}`);
680
+ const target = helperProxy.resolveProxyTarget(targetPort, originalUrl);
681
+ Object.assign(proxyState, { active: true, targetPort: target.port, originalUrl: originalUrl || null, target, startedAt: nowIso() });
682
+ console.log(`[proxy] enabled -> ${target.hostHeader}${originalUrl ? ` url=${originalUrl}` : ''}`);
644
683
  return getProxySummary();
645
684
  }
646
685
 
647
686
  function stopProxy() {
648
687
  const prev = getProxySummary();
649
- Object.assign(proxyState, { active: false, targetPort: null, originalUrl: null, startedAt: null });
688
+ Object.assign(proxyState, { active: false, targetPort: null, originalUrl: null, target: null, startedAt: null });
650
689
  console.log(`[proxy] disabled${prev?.targetPort ? ` (was ${prev.targetPort})` : ''}`);
651
690
  return { active: false };
652
691
  }
@@ -661,11 +700,11 @@ function forwardHeaders(headers, targetHost) {
661
700
  }
662
701
 
663
702
  function proxyRequest(req, res) {
664
- const targetHost = `127.0.0.1:${proxyState.targetPort}`;
665
- const url = `http://${targetHost}${req.url}`;
666
- console.log(`[proxy] ${req.method} ${req.url} -> ${url}`);
703
+ const target = proxyState.target;
704
+ console.log(`[proxy] ${req.method} ${req.url} -> http://${target.hostHeader}${req.url}`);
667
705
 
668
- const upstream = http.request(url, { method: req.method, headers: forwardHeaders(req.headers, targetHost) }, (upRes) => {
706
+ const options = helperProxy.upstreamRequestOptions(target, req, forwardHeaders(req.headers, target.hostHeader));
707
+ const upstream = http.request(options, (upRes) => {
669
708
  console.log(`[proxy] ${req.method} ${req.url} <- ${upRes.statusCode}`);
670
709
  if (!shouldInjectIntoProxyHtml(req, upRes)) {
671
710
  res.writeHead(upRes.statusCode || 502, upRes.statusMessage, upRes.headers);
@@ -701,12 +740,13 @@ function proxyRequest(req, res) {
701
740
  }
702
741
 
703
742
  function proxyUpgrade(req, socket, head) {
704
- const targetHost = `127.0.0.1:${proxyState.targetPort}`;
705
- console.log(`[proxy-ws] ${req.url} -> ${targetHost}`);
706
- const upstream = http.request(`http://${targetHost}${req.url}`, {
707
- method: 'GET',
708
- headers: { ...forwardHeaders(req.headers, targetHost), connection: req.headers.connection || 'Upgrade', upgrade: req.headers.upgrade || 'websocket' },
709
- });
743
+ const target = proxyState.target;
744
+ console.log(`[proxy-ws] ${req.url} -> ${target.hostHeader}`);
745
+ const upstream = http.request(helperProxy.upstreamRequestOptions(target, { url: req.url, method: 'GET' }, {
746
+ ...forwardHeaders(req.headers, target.hostHeader),
747
+ connection: req.headers.connection || 'Upgrade',
748
+ upgrade: req.headers.upgrade || 'websocket',
749
+ }));
710
750
  upstream.on('upgrade', (upRes, upSock, upHead) => {
711
751
  const lines = [`HTTP/1.1 ${upRes.statusCode || 101} ${upRes.statusMessage || 'Switching Protocols'}`];
712
752
  for (const [k, v] of Object.entries(upRes.headers)) {
@@ -820,8 +860,11 @@ function updateClientFromMessage(client, msg) {
820
860
 
821
861
  // ─── WebSocket attachment ─────────────────────────────────────────────────────
822
862
 
823
- function attachSocket(socket) {
863
+ const clientIdBindings = pageSocketTrust.createClientIdBindings();
864
+
865
+ function attachSocket(socket, socketOrigin) {
824
866
  const id = crypto.randomUUID();
867
+ clientIdBindings.claim(id, socketOrigin);
825
868
  const client = {
826
869
  socket, clientId: id,
827
870
  connectedAt: nowIso(), lastMessageAt: nowIso(), lastHelloAt: null,
@@ -848,6 +891,15 @@ function attachSocket(socket) {
848
891
  if (frame.opcode !== 0x1) continue;
849
892
  try {
850
893
  const msg = JSON.parse(frame.payload.toString('utf8'));
894
+ const rejected = pageSocketTrust.checkPageMessage({
895
+ bindings: clientIdBindings, socketOrigin, currentClientId: client.clientId, msg,
896
+ });
897
+ if (rejected) {
898
+ console.warn(`[ws] rejected message from origin=${socketOrigin || 'none'}: ${rejected}`);
899
+ wsSend(socket, JSON.stringify({ type: 'error', message: rejected }));
900
+ socket.destroy();
901
+ return;
902
+ }
851
903
  const prevId = client.clientId;
852
904
  updateClientFromMessage(client, msg);
853
905
  if (client.clientId !== prevId) { clients.delete(prevId); clients.set(client.clientId, client); }
@@ -871,6 +923,8 @@ function attachSocket(socket) {
871
923
  ack: true,
872
924
  action: msg.action || 'page-reload',
873
925
  clientId: client.clientId,
926
+ ...(typeof msg.earlyStartup === 'string' ? { earlyStartup: msg.earlyStartup } : {}),
927
+ ...(msg.ok === false && typeof msg.error === 'string' ? { error: msg.error } : {}),
874
928
  });
875
929
  }
876
930
  } catch (e) {
@@ -1091,6 +1145,11 @@ async function handleChippyHttp(req, res, url) {
1091
1145
  return true;
1092
1146
  }
1093
1147
 
1148
+ if (method === 'GET' && p === `${CHIPPY_PREFIX}/startup-handoff.js`) {
1149
+ serveExtensionFile(res, 'startup-handoff.js', 'application/javascript; charset=utf-8');
1150
+ return true;
1151
+ }
1152
+
1094
1153
  if (method === 'GET' && p === `${CHIPPY_PREFIX}/avalanche.iife.js`) {
1095
1154
  serveExtensionFile(res, 'avalanche.iife.js', 'application/javascript; charset=utf-8', (src) =>
1096
1155
  `${src}\n;(() => {\n const api = globalThis.__AVALANCHE__;\n if (api && api.default && typeof api.default === 'object') {\n Object.assign(api, api.default);\n }\n})();\n`);
@@ -1199,6 +1258,8 @@ async function handleChippyHttp(req, res, url) {
1199
1258
  const clientId = p.split('/')[4];
1200
1259
  try {
1201
1260
  const body = await parseJsonBody(req);
1261
+ const modeError = detectionModeError(body?.options);
1262
+ if (modeError) { json(res, 400, { ok: false, error: modeError }); return true; }
1202
1263
  const result = await requestAvalanche(clientId, body);
1203
1264
  json(res, result.code, result.payload);
1204
1265
  } catch (e) {
@@ -1389,9 +1450,8 @@ async function handleChippyHttp(req, res, url) {
1389
1450
  const autoStart = body.autoStart !== false;
1390
1451
  if (autoStart) {
1391
1452
  const incomingOptions = body.options && typeof body.options === 'object' ? body.options : {};
1392
- // TODO: Validate detectionMode against the detector's canonical
1393
- // modes at this transport boundary. An arbitrary string can currently
1394
- // reach createAvalancheDetector and leave its internal mode state unset.
1453
+ const modeError = detectionModeError(incomingOptions);
1454
+ if (modeError) { json(res, 400, { ok: false, error: modeError }); return true; }
1395
1455
  const arm = {
1396
1456
  autoStart: true,
1397
1457
  options: {
@@ -1439,10 +1499,22 @@ async function handleChippyHttp(req, res, url) {
1439
1499
  const action = resolved.client.integration?.kind === 'storybook'
1440
1500
  ? 'storybook-force-remount'
1441
1501
  : 'reload';
1442
- const sent = sendToClient(resolved.clientId, { type: action, requestId });
1502
+ // An armed page gets its arm with the reload or remount, so Avalanche is
1503
+ // installed before React's first commit.
1504
+ const pageOrigin = normalizeArmOrigin(resolved.client.page?.url || '');
1505
+ const arm = armedAvalancheByClient.get(resolved.clientId) || (pageOrigin && armedAvalanche.get(pageOrigin)) || null;
1506
+ const command = arm ? { type: action, requestId, avalanche: arm } : { type: action, requestId };
1507
+ const sent = sendToClient(resolved.clientId, command);
1443
1508
  if (!sent.ok) { json(res, sent.code, { ok: false, error: sent.error }); return true; }
1444
1509
  const ack = await waitForPendingResult(pendingReloadAck, requestId, 1500, 'Timeout waiting for reload ack');
1445
- json(res, ack.ok ? 200 : 504, { ...ack, reloading: sent.clientId, resolvedFrom: sent.resolvedFrom, requestId });
1510
+ // 504: no ack arrived. 409: the page answered but could not reload.
1511
+ const refused = ack.ok && ack.result?.ok === false;
1512
+ const status = !ack.ok ? 504 : refused ? 409 : 200;
1513
+ json(res, status, {
1514
+ ...ack,
1515
+ ...(refused ? { ok: false, error: ack.result.error || 'The page did not reload' } : {}),
1516
+ reloading: sent.clientId, resolvedFrom: sent.resolvedFrom, requestId,
1517
+ });
1446
1518
  } catch (e) { json(res, 400, { ok: false, error: e.message }); }
1447
1519
  return true;
1448
1520
  }
@@ -1497,7 +1569,7 @@ async function handleChippyHttp(req, res, url) {
1497
1569
  if (!delivered.ok) { json(res, delivered.code, { ok: false, error: delivered.error }); return true; }
1498
1570
  // Wait for computed-style-result from the page
1499
1571
  const result = await waitForPendingResult(pendingComputedStyle, requestId, timeoutMs, 'Timeout waiting for computed style result');
1500
- json(res, result.ok ? 200 : 504, { ...result, targetClientId: delivered.clientId, resolvedFrom: delivered.resolvedFrom, requestId });
1572
+ json(res, pageResultStatus(result), { ...withPageResultHint(result), targetClientId: delivered.clientId, resolvedFrom: delivered.resolvedFrom, requestId });
1501
1573
  } catch (e) { json(res, 400, { ok: false, error: e.message }); }
1502
1574
  return true;
1503
1575
  }
@@ -1615,7 +1687,7 @@ async function handleChippyHttp(req, res, url) {
1615
1687
  const delivered = sendToClient(clientId, wsCommand);
1616
1688
  if (!delivered.ok) { json(res, delivered.code, { ok: false, error: delivered.error }); return true; }
1617
1689
  const result = await waitForPendingResult(pendingEvalProbe, requestId, timeoutMs, 'Timeout waiting for eval-probe result');
1618
- json(res, result.ok ? 200 : 504, { ...result, targetClientId: delivered.clientId, resolvedFrom: delivered.resolvedFrom, requestId });
1690
+ json(res, pageResultStatus(result), { ...withPageResultHint(result), targetClientId: delivered.clientId, resolvedFrom: delivered.resolvedFrom, requestId });
1619
1691
  } catch (e) { json(res, 400, { ok: false, error: e.message }); }
1620
1692
  return true;
1621
1693
  }
@@ -1633,22 +1705,6 @@ async function handleChippyHttp(req, res, url) {
1633
1705
  return true;
1634
1706
  }
1635
1707
 
1636
- // Proxy bootstrap (browser redirect)
1637
- if (method === 'GET' && p === `${CHIPPY_PREFIX}/proxy/go`) {
1638
- try {
1639
- const targetPort = url.searchParams.get('targetPort');
1640
- const originalUrl = url.searchParams.get('originalUrl') || null;
1641
- const redirectTo = url.searchParams.get('redirectTo') || '/';
1642
- startProxy(targetPort, originalUrl);
1643
- console.log(`[proxy-bootstrap] redirect -> ${redirectTo}`);
1644
- text(res, 200, `<!doctype html><meta http-equiv="refresh" content="0;url=${redirectTo.replace(/"/g, '&quot;')}"><title>Chippy</title><p>Redirecting…</p>`, 'text/html; charset=utf-8');
1645
- } catch (e) {
1646
- console.error(`[proxy-bootstrap] failed: ${e.message}`);
1647
- text(res, 400, `Could not start proxy: ${e.message}`);
1648
- }
1649
- return true;
1650
- }
1651
-
1652
1708
  return false;
1653
1709
  }
1654
1710
 
@@ -1657,7 +1713,7 @@ function handleChippyUpgrade(req, socket) {
1657
1713
  if (!key) { socket.write('HTTP/1.1 400 Bad Request\r\n\r\n'); socket.destroy(); return; }
1658
1714
  const accept = crypto.createHash('sha1').update(key + WS_GUID).digest('base64');
1659
1715
  socket.write(`HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: ${accept}\r\n\r\n`);
1660
- attachSocket(socket);
1716
+ attachSocket(socket, pageSocketTrust.handshakeOrigin(req.headers));
1661
1717
  }
1662
1718
 
1663
1719
  // ─── Server ───────────────────────────────────────────────────────────────────
@@ -1666,10 +1722,11 @@ function chippyRoute(pathname) {
1666
1722
  return isChippyPath(pathname) ? pathname.slice(CHIPPY_PREFIX.length) : null;
1667
1723
  }
1668
1724
 
1669
- function rejectUnauthorized(req, pathname) {
1725
+ function rejectUnauthorized(req, url) {
1670
1726
  return helperAccess.authorizeRequest({
1671
1727
  method: req.method || 'GET',
1672
- route: chippyRoute(pathname),
1728
+ route: chippyRoute(url.pathname),
1729
+ query: url.searchParams,
1673
1730
  headers: req.headers,
1674
1731
  token: helperToken,
1675
1732
  loopback: loopbackBind,
@@ -1678,7 +1735,7 @@ function rejectUnauthorized(req, pathname) {
1678
1735
 
1679
1736
  const server = http.createServer(async (req, res) => {
1680
1737
  const url = new URL(req.url, baseUrlFromReq(req));
1681
- const denied = rejectUnauthorized(req, url.pathname);
1738
+ const denied = rejectUnauthorized(req, url);
1682
1739
  if (denied) { json(res, denied.status, { ok: false, error: denied.error }); return; }
1683
1740
  if (isChippyPath(url.pathname)) {
1684
1741
  if (!(await handleChippyHttp(req, res, url))) json(res, 404, { ok: false, error: 'Chippy route not found' });
@@ -1689,8 +1746,9 @@ const server = http.createServer(async (req, res) => {
1689
1746
  });
1690
1747
 
1691
1748
  server.on('upgrade', (req, socket, head) => {
1692
- const p = new URL(req.url, 'http://127.0.0.1').pathname;
1693
- const denied = rejectUnauthorized(req, p);
1749
+ const upgradeUrl = new URL(req.url, 'http://127.0.0.1');
1750
+ const p = upgradeUrl.pathname;
1751
+ const denied = rejectUnauthorized(req, upgradeUrl);
1694
1752
  if (denied) { socket.write(`HTTP/1.1 ${denied.status} ${denied.status === 401 ? 'Unauthorized' : 'Forbidden'}\r\n\r\n`); socket.destroy(); return; }
1695
1753
  if (p === `${CHIPPY_PREFIX}/ws`) { handleChippyUpgrade(req, socket); return; }
1696
1754
  if (proxyState.active && !isChippyPath(p)) { proxyUpgrade(req, socket, head); return; }
@@ -179,6 +179,22 @@ stop_helper() {
179
179
  return 1
180
180
  }
181
181
 
182
+ # The helper explains failures in its JSON body (`error`, `hint`); show them
183
+ # instead of leaving only the HTTP status.
184
+ print_helper_error_body() {
185
+ [[ -s "$1" ]] || return 0
186
+ node -e '
187
+ const fs = require("fs");
188
+ const raw = fs.readFileSync(process.argv[1], "utf8");
189
+ let body;
190
+ try { body = JSON.parse(raw); } catch { process.stdout.write(` helper: ${raw.trim().slice(0, 500)}\n`); process.exit(0); }
191
+ for (const key of ["error", "hint", "next"]) {
192
+ const value = body && body[key];
193
+ if (value) process.stdout.write(` ${key}: ${typeof value === "string" ? value : JSON.stringify(value)}\n`);
194
+ }
195
+ ' "$1" 2>/dev/null || true
196
+ }
197
+
182
198
  run_curl() {
183
199
  local method="$1"
184
200
  local url="$2"
@@ -245,6 +261,7 @@ run_curl() {
245
261
  if [[ -n "$stderr_text" ]]; then
246
262
  echo " → curl: $stderr_text" >&2
247
263
  fi
264
+ print_helper_error_body "$response_file" >&2
248
265
  rm -f "$response_file" "$stderr_file"
249
266
  if [[ "$http_code" =~ ^[0-9]+$ && "$http_code" -ge 400 ]]; then
250
267
  return 22
@@ -252,6 +269,16 @@ run_curl() {
252
269
  return "$code"
253
270
  }
254
271
 
272
+ require_detection_mode() {
273
+ case "$2" in
274
+ sources|full|stats|amplifiers) ;;
275
+ *)
276
+ echo "$1 detectionMode must be sources, full, stats, or amplifiers (got '$2')" >&2
277
+ exit 1
278
+ ;;
279
+ esac
280
+ }
281
+
255
282
  post_json() {
256
283
  local path="$1"
257
284
  local body="$2"
@@ -303,6 +330,7 @@ case "$cmd" in
303
330
  ;;
304
331
  inject-url)
305
332
  echo "Import integration is document-scoped: a full reload/navigation removes Chippy and requires the user or automation to inject it again." >&2
333
+ echo "The classic inject.js installs Chippy only; load $BASE_URL/avalanche.iife.js after it for bridge and Avalanche commands, or import $BASE_URL/inject.mjs instead." >&2
306
334
  printf '%s/inject.js\n' "$BASE_URL"
307
335
  ;;
308
336
  recipes)
@@ -964,6 +992,7 @@ case "$cmd" in
964
992
  echo "avalanche-install requires <clientId> [detectionMode]" >&2
965
993
  exit 1
966
994
  fi
995
+ require_detection_mode avalanche-install "$detection_mode"
967
996
  body="{\"action\":\"install\",\"options\":{\"captureFibers\":true,\"detectionMode\":\"$detection_mode\",\"historySize\":1000}}"
968
997
  post_avalanche "$client_id" "$body"
969
998
  ;;
@@ -974,6 +1003,7 @@ case "$cmd" in
974
1003
  echo "avalanche-recapture requires <clientId> [detectionMode]" >&2
975
1004
  exit 1
976
1005
  fi
1006
+ require_detection_mode avalanche-recapture "$detection_mode"
977
1007
  body="{\"action\":\"install\",\"options\":{\"captureFibers\":true,\"detectionMode\":\"$detection_mode\",\"historySize\":1000}}"
978
1008
  post_avalanche "$client_id" "$body" >/dev/null
979
1009
  post_avalanche "$client_id" '{"action":"clear"}' >/dev/null
@@ -1083,10 +1113,7 @@ case "$cmd" in
1083
1113
  echo "avalanche-arm captureFibers must be true or false" >&2
1084
1114
  exit 1
1085
1115
  fi
1086
- if [[ "$detection_mode" != "sources" && "$detection_mode" != "full" && "$detection_mode" != "stats" && "$detection_mode" != "amplifiers" ]]; then
1087
- echo "avalanche-arm detectionMode must be sources, full, stats, or amplifiers" >&2
1088
- exit 1
1089
- fi
1116
+ require_detection_mode avalanche-arm "$detection_mode"
1090
1117
  body="{\"clientId\":$(json_escape "$client_id"),\"origin\":$(json_escape "$origin"),\"autoStart\":true,\"max\":$max_commits,\"options\":{\"captureFibers\":$capture_fibers,\"detectionMode\":\"$detection_mode\"}}"
1091
1118
  post_json "/api/avalanche-arm" "$body" >/dev/null
1092
1119
  # Reset first (clear prior capture), then install detector — no reload needed
@@ -1142,7 +1169,15 @@ case "$cmd" in
1142
1169
  "$0" page-reload "$client_id"
1143
1170
  echo >&2
1144
1171
  echo "== wait ==" >&2
1145
- "$0" page-wait-reload "$client_id" "$timeout_ms"
1172
+ wait_result=$("$0" page-wait-reload "$client_id" "$timeout_ms")
1173
+ printf '%s\n' "$wait_result"
1174
+ if ! printf '%s' "$wait_result" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{try{process.exit(JSON.parse(s).reloaded===true?0:1)}catch{process.exit(1)}})'; then
1175
+ echo "reload-with-avalanche: the page did not reconnect within ${timeout_ms}ms after the reload." >&2
1176
+ echo " meaning: Chippy was not loaded again in the new document, so nothing captured its commits" >&2
1177
+ echo " next: with Mode A, load the runtimes as init scripts (installHelpers(page, { init: true })) so they return on reload," >&2
1178
+ echo " or skip the reload: avalanche-install, then trigger the interaction and read avalanche-status" >&2
1179
+ exit 1
1180
+ fi
1146
1181
  if [[ "$settle_ms" -gt 0 ]]; then
1147
1182
  echo >&2
1148
1183
  echo "== settle (${settle_ms}ms) ==" >&2
@@ -1187,8 +1222,7 @@ case "$cmd" in
1187
1222
  };
1188
1223
  process.stdout.write(JSON.stringify(body));
1189
1224
  ' "$url" "$probe_json" "$browser_name" "$headless") || exit 1
1190
- curl -fsSL ${AUTH_HEADER[@]+"${AUTH_HEADER[@]}"} -X POST -H 'content-type: application/json' \
1191
- "$BASE_URL/api/pages/$client_id/handoff-to-playwright" --data-binary "$body"
1225
+ post_json "/api/pages/$client_id/handoff-to-playwright" "$body"
1192
1226
  ;;
1193
1227
  proxy-state)
1194
1228
  curl "${CURL_OPTS[@]}" "$BASE_URL/api/proxy/state"
@@ -29,16 +29,15 @@ const PUBLIC_GET_PATHS = new Set([
29
29
  '/avalanche.es.js.map',
30
30
  '/avalanche.iife.js.map',
31
31
  '/devtools-hook-shim.js',
32
- '/api/avalanche-arm',
33
- '/proxy/go',
32
+ '/startup-handoff.js',
34
33
  '/ws',
35
34
  ]);
36
35
 
37
36
  // Routes the published browser extension calls from its service worker. The
38
37
  // extension sends no token, so these stay open to extension origins and
39
- // non-browser clients on every bind. They list pages and arm capture; they do
40
- // not run page code.
41
- const EXTENSION_ROUTES = new Set(['GET /api/pages', 'POST /api/avalanche-arm']);
38
+ // non-browser clients on every bind. They list pages and read or arm capture;
39
+ // they do not run page code.
40
+ const EXTENSION_ROUTES = new Set(['GET /api/pages', 'GET /api/avalanche-arm', 'POST /api/avalanche-arm']);
42
41
 
43
42
  function stateDirectory(env = process.env) {
44
43
  return env.CHIPPY_STATE_DIR || path.join(os.homedir(), '.chippy');
@@ -109,6 +108,19 @@ function isExtensionOrigin(origin) {
109
108
  return typeof origin === 'string' && /^(chrome|moz)-extension:\/\//.test(origin);
110
109
  }
111
110
 
111
+ function originOf(value) {
112
+ try { return new URL(value).origin; } catch { return null; }
113
+ }
114
+
115
+ // A page may ask whether its own origin is armed (the Storybook addon does this
116
+ // at preview start). It may not list other origins or look up client ids.
117
+ function isSelfArmLookup(method, route, origin, query) {
118
+ if (method !== 'GET' || route !== '/api/avalanche-arm' || !query || typeof origin !== 'string') return false;
119
+ if (query.get('clientId')) return false;
120
+ const asked = query.get('origin');
121
+ return !!asked && originOf(asked) === origin;
122
+ }
123
+
112
124
  function tokensMatch(expected, received) {
113
125
  if (typeof received !== 'string' || !expected) return false;
114
126
  const a = Buffer.from(expected);
@@ -117,7 +129,8 @@ function tokensMatch(expected, received) {
117
129
  }
118
130
 
119
131
  // Returns null when the request may proceed, otherwise { status, error }.
120
- function authorizeRequest({ method, route, headers, token, loopback, env = process.env }) {
132
+ // `query` is the request's URLSearchParams.
133
+ function authorizeRequest({ method, route, headers, token, loopback, query = null, env = process.env }) {
121
134
  if (loopback && !isAllowedHost(headers.host, env)) {
122
135
  return { status: 403, error: 'Host header is not a loopback host; set CHIPPY_HELPER_ALLOWED_HOSTS to allow it' };
123
136
  }
@@ -127,6 +140,7 @@ function authorizeRequest({ method, route, headers, token, loopback, env = proce
127
140
  if (kind === 'extension') {
128
141
  const origin = headers.origin;
129
142
  if (origin === undefined || isExtensionOrigin(origin)) return null;
143
+ if (isSelfArmLookup(method, route, origin, query)) return null;
130
144
  }
131
145
  return { status: 401, error: 'Missing or invalid X-Chippy-Token header' };
132
146
  }
@@ -0,0 +1,87 @@
1
+ /* eslint-disable */
2
+ // Pure helpers for the helper's proxy mode: where to forward requests, and how
3
+ // to put Chippy into the proxied HTML document.
4
+
5
+ const LOOPBACK_HOSTNAMES = new Set(['localhost', '127.0.0.1', '::1']);
6
+
7
+ function isLoopbackHostname(hostname) {
8
+ const name = String(hostname || '').toLowerCase().replace(/^\[|\]$/g, '');
9
+ return LOOPBACK_HOSTNAMES.has(name) || /^127(?:\.\d{1,3}){3}$/.test(name) || name.endsWith('.localhost');
10
+ }
11
+
12
+ // The proxy forwards only to this machine. Dev servers bind `localhost`,
13
+ // `127.0.0.1`, or `::1` depending on the tool and platform (Vite on macOS binds
14
+ // `::1`), so the original page URL decides the loopback name when it names one.
15
+ // `localhost` is the default because it resolves to both address families.
16
+ function resolveProxyTarget(targetPort, originalUrl) {
17
+ const port = Number(targetPort);
18
+ if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error(`Invalid target port: ${targetPort}`);
19
+ let hostname = 'localhost';
20
+ if (originalUrl) {
21
+ let parsed;
22
+ try { parsed = new URL(String(originalUrl)); } catch { throw new Error(`Invalid originalUrl: ${originalUrl}`); }
23
+ const named = parsed.hostname.replace(/^\[|\]$/g, '');
24
+ if (!isLoopbackHostname(named)) {
25
+ throw new Error(`Proxy targets must be on this machine; originalUrl host ${parsed.hostname} is not a loopback name`);
26
+ }
27
+ hostname = named;
28
+ }
29
+ const hostHeader = `${hostname.includes(':') ? `[${hostname}]` : hostname}:${port}`;
30
+ return { hostname, port, hostHeader };
31
+ }
32
+
33
+ // Options for http.request. `autoSelectFamily` tries every address `localhost`
34
+ // resolves to, so a server listening on only one family is still reached.
35
+ function upstreamRequestOptions(target, req, headers) {
36
+ return {
37
+ host: target.hostname,
38
+ port: target.port,
39
+ path: req.url,
40
+ method: req.method,
41
+ headers,
42
+ autoSelectFamily: true,
43
+ };
44
+ }
45
+
46
+ const HEAD_OPEN = /<head(?:\s[^>]*)?>/i;
47
+ const HTML_OPEN = /<html(?:\s[^>]*)?>/i;
48
+ const DOCTYPE = /^\s*<!doctype[^>]*>/i;
49
+
50
+ // Classic scripts at the start of <head> run before the document's module
51
+ // scripts, so the DevTools hook exists when react-dom evaluates.
52
+ function insertAtHeadStart(html, markup) {
53
+ for (const pattern of [HEAD_OPEN, HTML_OPEN, DOCTYPE]) {
54
+ const match = pattern.exec(html);
55
+ if (match) {
56
+ const at = match.index + match[0].length;
57
+ return `${html.slice(0, at)}\n${markup}\n${html.slice(at)}`;
58
+ }
59
+ }
60
+ return `${markup}\n${html}`;
61
+ }
62
+
63
+ function insertAtBodyEnd(html, markup) {
64
+ const lower = html.toLowerCase();
65
+ for (const tag of ['</body>', '</html>']) {
66
+ const i = lower.lastIndexOf(tag);
67
+ if (i >= 0) return `${html.slice(0, i)}${markup}\n${html.slice(i)}`;
68
+ }
69
+ return `${html}\n${markup}`;
70
+ }
71
+
72
+ // `earlyScripts` are helper-relative script paths that must run before page
73
+ // code; `bridgeBundle` is inline markup that connects the page to the helper.
74
+ function injectProxyDocument(html, { earlyScripts = [], bridgeBundle = '' } = {}) {
75
+ const early = earlyScripts
76
+ .map((src) => `<script data-chippy-proxy-early="true" src="${src}"></script>`)
77
+ .join('\n');
78
+ const withEarly = early ? insertAtHeadStart(html, early) : html;
79
+ return bridgeBundle ? insertAtBodyEnd(withEarly, bridgeBundle) : withEarly;
80
+ }
81
+
82
+ module.exports = {
83
+ injectProxyDocument,
84
+ isLoopbackHostname,
85
+ resolveProxyTarget,
86
+ upstreamRequestOptions,
87
+ };