diff --git a/README.md b/README.md
index d5e6c65..7734da5 100644
--- a/README.md
+++ b/README.md
@@ -33,6 +33,11 @@ tron version
> [Tor Browser](https://www.torproject.org/) — it's much safer.** Requires the
> `tor` daemon installed. Details: [`docs/tor-onion-mode.md`](docs/tor-onion-mode.md).
+> **🤘 Pit** in the AI sidebar resolves Moshpit names (`.eggs`, `.moshpit`, …) for
+> the current session with one click — no root, nothing on the machine changes,
+> clearnet names untouched. For every app on the box run `moshcode dns enable`
+> instead. Details: [`docs/moshpit-pit-toggle.md`](docs/moshpit-pit-toggle.md).
+
Also packaged for **macOS · Windows · Debian/Ubuntu (.deb) · Fedora/RHEL (.rpm) ·
Arch (AUR) · Gentoo · NixOS · Snap · Flatpak · AppImage · FreeBSD** — and arm64
Linux phones (Librem 5 / PinePhone / Ubuntu Touch). See
diff --git a/apps/desktop/extensions/ai-sidebar/README.md b/apps/desktop/extensions/ai-sidebar/README.md
index dd03260..38a3a3e 100644
--- a/apps/desktop/extensions/ai-sidebar/README.md
+++ b/apps/desktop/extensions/ai-sidebar/README.md
@@ -53,7 +53,8 @@ CRX and cannot be replaced by one.
| File | Role |
| --- | --- |
| `manifest.json` | MV3 manifest (side_panel, storage, tabs, management, host permissions) |
-| `background.js` | Opens the panel on action click; resolves store-install targets |
+| `background.js` | Opens the panel on action click; resolves store-install targets; the 🧅 Tor and 🤘 Pit toggles (`chrome.proxy` + the launcher's helper) |
+| `pit-proxy.js` | The Pit PAC: hosts the system resolver cannot answer go to the helper's Moshpit resolver, everything else DIRECT (`docs/moshpit-pit-toggle.md`) |
| `install-helper.js` | The "Add to TronBrowser" button on Web Store detail pages |
| `install-state.js` | Pure decision + `chrome.management` lookup behind that button |
| `sidepanel.html/.css/.js` | The chat UI |
diff --git a/apps/desktop/extensions/ai-sidebar/background.js b/apps/desktop/extensions/ai-sidebar/background.js
index de08e77..d7e09a7 100644
--- a/apps/desktop/extensions/ai-sidebar/background.js
+++ b/apps/desktop/extensions/ai-sidebar/background.js
@@ -1,4 +1,5 @@
import { decideInstallTarget, lookupInstalled } from './install-state.js';
+import { PIT_SOCKS_PORT, pitProxyConfig } from './pit-proxy.js';
// Open the AI side panel when the toolbar action is clicked.
chrome.sidePanel
@@ -256,6 +257,13 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
if (msg?.type === 'tor-set') {
(async () => {
if (msg.on) {
+ // Tor and the pit are exclusive: the pit's PAC asks the system resolver
+ // about every host, which under Tor would leak lookups outside it.
+ const { pitEnabled } = await chrome.storage.local.get('pitEnabled');
+ if (pitEnabled) {
+ await disablePit();
+ await stopPitViaHelper();
+ }
const started = await startTorViaHelper();
if (started.error === 'unreachable') {
sendResponse({ enabled: false, started: { error: 'unreachable' } });
@@ -293,21 +301,110 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
}
});
-// Tor defaults OFF on every fresh browser start — nobody is routed through Tor
-// unless they flip the toggle. chrome.storage.session is wiped on browser
-// restart, so it tells a fresh launch from a service-worker restart mid-session.
+// --- Pit toggle ----------------------------------------------------------
+// Resolves Moshpit names (.eggs, .moshpit, …) in THIS session only. The same
+// helper that starts Tor runs a loopback SOCKS5 resolver on demand (/pit/*);
+// the extension installs the PAC from pit-proxy.js, which sends only hosts the
+// system resolver has no answer for to it — clearnet wins, nothing on the
+// machine changes, no root. Whole-machine resolution is still
+// `moshcode dns enable`. Off again on every fresh browser start, like Tor.
+async function setPitBadge(on) {
+ try {
+ await chrome.action.setBadgeText({ text: on ? 'PIT' : '' });
+ await chrome.action.setBadgeBackgroundColor({ color: '#a6ff1a' }); // moshcode acid
+ if (chrome.action.setBadgeTextColor) await chrome.action.setBadgeTextColor({ color: '#0a1400' });
+ await chrome.action.setTitle({ title: on ? 'TronBrowser — Pit ON' : 'TronBrowser' });
+ } catch (_) { /* action API may be unavailable */ }
+}
+
+async function enablePit() {
+ await chrome.proxy.settings.set({ value: pitProxyConfig(PIT_SOCKS_PORT), scope: 'regular' });
+ await chrome.storage.local.set({ pitEnabled: true });
+ try { await chrome.storage.session.set({ pitSession: true }); } catch (_) { /* no-op */ }
+ await setPitBadge(true);
+}
+
+async function disablePit() {
+ // Only hand the proxy back to the OS when Tor is not holding it.
+ const { torEnabled } = await chrome.storage.local.get('torEnabled');
+ if (!torEnabled) {
+ try {
+ await chrome.proxy.settings.set({ value: { mode: 'system' }, scope: 'regular' });
+ } catch (_) { /* fall through to clear */ }
+ try { await chrome.proxy.settings.clear({ scope: 'regular' }); } catch (_) { /* already clear */ }
+ }
+ await chrome.storage.local.set({ pitEnabled: false });
+ try { await chrome.storage.session.remove('pitSession'); } catch (_) { /* no-op */ }
+ await setPitBadge(false);
+}
+
+async function stopPitViaHelper() {
+ try {
+ const ctrl = new AbortController();
+ const t = setTimeout(() => ctrl.abort(), 5000);
+ await fetch(`${TOR_HELPER}/pit/stop`, { method: 'POST', signal: ctrl.signal });
+ clearTimeout(t);
+ } catch (_) { /* helper not running — nothing to stop */ }
+}
+
+chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
+ if (msg?.type === 'pit-set') {
+ (async () => {
+ if (msg.on) {
+ const { torEnabled } = await chrome.storage.local.get('torEnabled');
+ if (torEnabled) {
+ sendResponse({ enabled: false, error: 'tor-on' });
+ return;
+ }
+ let started;
+ try {
+ started = await helperJson('/pit/start', 'POST');
+ } catch (_) {
+ sendResponse({ enabled: false, error: 'unreachable' });
+ return;
+ }
+ if (!started || !started.started) {
+ sendResponse({ enabled: false, error: (started && started.error) || 'pit-failed' });
+ return;
+ }
+ await enablePit();
+ sendResponse({ enabled: true, check: started.check || null, port: started.port });
+ } else {
+ await disablePit();
+ await stopPitViaHelper();
+ sendResponse({ enabled: false });
+ }
+ })();
+ return true; // async sendResponse
+ }
+ if (msg?.type === 'pit-status') {
+ (async () => {
+ const { pitEnabled } = await chrome.storage.local.get('pitEnabled');
+ sendResponse({ enabled: !!pitEnabled });
+ })();
+ return true;
+ }
+});
+
+// Tor and the pit default OFF on every fresh browser start — nobody is routed
+// anywhere unless they flip a toggle. chrome.storage.session is wiped on
+// browser restart, so it tells a fresh launch from a service-worker restart
+// mid-session.
(async () => {
try {
- const { torSession } = await chrome.storage.session.get('torSession');
+ const { torSession, pitSession } = await chrome.storage.session.get(['torSession', 'pitSession']);
if (torSession) {
// Same session, SW just restarted → keep Tor on (re-apply the proxy).
await enableTor();
+ } else if (pitSession) {
+ await enablePit();
} else {
- // Fresh browser start. If local still says Tor was on (carried over from
- // the last run), clear it + any persisted proxy. Otherwise leave the
+ // Fresh browser start. If local still says a toggle was on (carried over
+ // from the last run), clear it + any persisted proxy. Otherwise leave the
// proxy untouched.
- const { torEnabled } = await chrome.storage.local.get('torEnabled');
+ const { torEnabled, pitEnabled } = await chrome.storage.local.get(['torEnabled', 'pitEnabled']);
if (torEnabled) await disableTor();
+ else if (pitEnabled) { await disablePit(); await stopPitViaHelper(); }
}
} catch (_) { /* best effort */ }
})();
diff --git a/apps/desktop/extensions/ai-sidebar/options.html b/apps/desktop/extensions/ai-sidebar/options.html
index d269842..8d10308 100644
--- a/apps/desktop/extensions/ai-sidebar/options.html
+++ b/apps/desktop/extensions/ai-sidebar/options.html
@@ -79,10 +79,14 @@
Account
Name resolution
- Moshpit names resolve at the operating system, not in the browser — run
- moshcode dns enable once on this machine and every application
- resolves them, not just this one. The browser used to do it here, which meant
- a hook on every navigation and names that worked in one app and not the next.
+ Two ways to reach Moshpit names (.eggs, .moshpit, …).
+ For the whole machine, run moshcode dns enable once and every
+ application resolves them, not just this one. For this browser session only —
+ a machine where DNS is not yours to change — click 🤘 Pit in
+ the sidebar: names the system resolver has no answer for are resolved through
+ the Moshpit resolver, clearnet names are untouched, nothing on the machine
+ changes, and it is off again on the next launch. https:// on a pit
+ name still needs the certificate that moshcode dns enable installs.
AI providers (bring your own keys)
diff --git a/apps/desktop/extensions/ai-sidebar/pit-proxy.js b/apps/desktop/extensions/ai-sidebar/pit-proxy.js
new file mode 100644
index 0000000..41e401f
--- /dev/null
+++ b/apps/desktop/extensions/ai-sidebar/pit-proxy.js
@@ -0,0 +1,52 @@
+// Moshpit ("the pit") session routing — what the 🤘 Pit toggle installs.
+//
+// Moshpit endings (.eggs, .moshpit, .yeah, …) live outside the ICANN root, so
+// the system resolver has no answer for them. `moshcode dns enable` teaches the
+// whole machine, but needs root. This is the no-root, this-browser-only route,
+// built like the Tor toggle: the launcher's helper runs a loopback SOCKS5
+// resolver (tron-tor-helper, /pit/*) that answers through the Moshpit
+// DNS-over-HTTPS resolver, and the extension points a PAC script at it.
+//
+// The PAC decides per host with `dnsResolve`: whatever the system resolver CAN
+// answer goes DIRECT, untouched; only a host it has no answer for goes to the
+// pit. That is the house policy — clearnet wins, the pit is the fallback — so no
+// ending list is needed and a real domain is never redirected. Pure functions,
+// tested in pit-proxy.test.js.
+
+/** Loopback port the helper's pit resolver listens on (tron-tor-helper PIT_SOCKS_PORT). */
+export const PIT_SOCKS_PORT = 9081;
+
+function checkPort(port) {
+ const p = Number(port);
+ if (!Number.isInteger(p) || p <= 0 || p > 65535) throw new Error(`bad pit port: ${port}`);
+ return p;
+}
+
+/**
+ * The PAC script. Kept to plain ES3-style JavaScript: Chromium evaluates it in
+ * its own PAC sandbox, where only the PAC helpers (dnsResolve, …) exist.
+ */
+export function buildPitPac(port = PIT_SOCKS_PORT) {
+ const p = checkPort(port);
+ return [
+ 'function FindProxyForURL(url, host) {',
+ " var h = String(host || '').toLowerCase();",
+ " if (h.charAt(h.length - 1) === '.') h = h.slice(0, -1);",
+ // Loopback, single-label intranet names and IP literals never touch the pit.
+ " if (!h || h === 'localhost' || h.indexOf('.') === -1) return 'DIRECT';",
+ " if (h.slice(-10) === '.localhost') return 'DIRECT';",
+ " if (/^[0-9.]+$/.test(h) || h.indexOf(':') !== -1) return 'DIRECT';",
+ // Clearnet wins: anything the system resolver knows stays exactly as it was.
+ " if (dnsResolve(h)) return 'DIRECT';",
+ ` return 'SOCKS5 127.0.0.1:${p}';`,
+ '}',
+ ].join('\n');
+}
+
+/** chrome.proxy.settings value for the pit route. */
+export function pitProxyConfig(port = PIT_SOCKS_PORT) {
+ // Not `mandatory`: if the PAC ever fails to evaluate, Chromium falls back to
+ // DIRECT and ordinary browsing keeps working — a pit name failing is the
+ // acceptable failure, every site failing is not.
+ return { mode: 'pac_script', pacScript: { data: buildPitPac(port) } };
+}
diff --git a/apps/desktop/extensions/ai-sidebar/pit-proxy.test.js b/apps/desktop/extensions/ai-sidebar/pit-proxy.test.js
new file mode 100644
index 0000000..6a1706e
--- /dev/null
+++ b/apps/desktop/extensions/ai-sidebar/pit-proxy.test.js
@@ -0,0 +1,55 @@
+import { describe, expect, it } from 'vitest';
+import { PIT_SOCKS_PORT, buildPitPac, pitProxyConfig } from './pit-proxy.js';
+
+// Run the PAC the way Chromium would: only the PAC helpers exist, and
+// dnsResolve is the system resolver — here a stub that knows two clearnet hosts.
+function findProxy(host, resolvable = ['example.com', 'www.github.io']) {
+ const dnsResolve = (h) => (resolvable.includes(h) ? '93.184.216.34' : null);
+ const find = new Function('dnsResolve', `${buildPitPac()}; return FindProxyForURL;`)(dnsResolve);
+ return find(`http://${host}/`, host);
+}
+
+describe('buildPitPac', () => {
+ it('sends a host the system resolver has no answer for to the pit resolver', () => {
+ expect(findProxy('scrambled.eggs')).toBe(`SOCKS5 127.0.0.1:${PIT_SOCKS_PORT}`);
+ expect(findProxy('anything.moshpit')).toBe(`SOCKS5 127.0.0.1:${PIT_SOCKS_PORT}`);
+ });
+
+ it('leaves a host the system resolver knows untouched (clearnet wins)', () => {
+ expect(findProxy('example.com')).toBe('DIRECT');
+ // .io is claimed as a Moshpit ending too; a name that resolves still goes direct.
+ expect(findProxy('www.github.io')).toBe('DIRECT');
+ });
+
+ it('never routes loopback, intranet names or IP literals through the pit', () => {
+ expect(findProxy('localhost')).toBe('DIRECT');
+ expect(findProxy('app.localhost')).toBe('DIRECT');
+ expect(findProxy('intranet')).toBe('DIRECT');
+ expect(findProxy('127.0.0.1')).toBe('DIRECT');
+ expect(findProxy('10.0.0.7')).toBe('DIRECT');
+ expect(findProxy('::1')).toBe('DIRECT');
+ expect(findProxy('')).toBe('DIRECT');
+ });
+
+ it('normalises case and a trailing dot before deciding', () => {
+ expect(findProxy('Example.COM.')).toBe('DIRECT');
+ expect(findProxy('Mosh.EGGS.')).toBe(`SOCKS5 127.0.0.1:${PIT_SOCKS_PORT}`);
+ });
+
+ it('uses the port it is given and refuses a bad one', () => {
+ expect(buildPitPac(1234)).toContain('SOCKS5 127.0.0.1:1234');
+ expect(() => buildPitPac(0)).toThrow(/bad pit port/);
+ expect(() => buildPitPac(70000)).toThrow(/bad pit port/);
+ expect(() => buildPitPac('nope')).toThrow(/bad pit port/);
+ });
+});
+
+describe('pitProxyConfig', () => {
+ it('is a non-mandatory inline PAC, so a broken script falls back to DIRECT', () => {
+ const cfg = pitProxyConfig();
+ expect(cfg.mode).toBe('pac_script');
+ expect(cfg.pacScript.data).toContain('function FindProxyForURL');
+ expect(cfg.pacScript.mandatory).toBeUndefined();
+ expect(cfg.pacScript.url).toBeUndefined();
+ });
+});
diff --git a/apps/desktop/extensions/ai-sidebar/sidepanel.css b/apps/desktop/extensions/ai-sidebar/sidepanel.css
index b485258..a45460c 100644
--- a/apps/desktop/extensions/ai-sidebar/sidepanel.css
+++ b/apps/desktop/extensions/ai-sidebar/sidepanel.css
@@ -27,6 +27,8 @@ header button { background: transparent; border: 1px solid var(--line); color: v
.tor-btn { font-size: 12px; white-space: nowrap; }
.tor-btn[aria-pressed="true"] { background: #7d4698; border-color: #7d4698; color: #fff; font-weight: 700; }
.tor-btn.busy { opacity: .6; cursor: progress; }
+/* The pit wears moshcode's acid green, so ON reads as "pit", not "Tor". */
+#pit[aria-pressed="true"] { background: #a6ff1a; border-color: #a6ff1a; color: #0a1400; }
.tor-status { padding: 6px 12px; font-size: 12px; border-bottom: 1px solid var(--line);
background: #0b1020; color: var(--muted); }
.tor-status.ok { color: #6ee7a8; }
diff --git a/apps/desktop/extensions/ai-sidebar/sidepanel.html b/apps/desktop/extensions/ai-sidebar/sidepanel.html
index 7de76b3..8b7dad4 100644
--- a/apps/desktop/extensions/ai-sidebar/sidepanel.html
+++ b/apps/desktop/extensions/ai-sidebar/sidepanel.html
@@ -15,6 +15,7 @@
+
diff --git a/apps/desktop/extensions/ai-sidebar/sidepanel.js b/apps/desktop/extensions/ai-sidebar/sidepanel.js
index 68429b2..4a775f8 100644
--- a/apps/desktop/extensions/ai-sidebar/sidepanel.js
+++ b/apps/desktop/extensions/ai-sidebar/sidepanel.js
@@ -1,6 +1,7 @@
import { PROVIDERS, chatStream } from './providers.js';
import { renderMarkdown } from './markdown.js';
import { storageGet } from './net.js';
+import { PIT_SOCKS_PORT } from './pit-proxy.js';
const el = (id) => document.getElementById(id);
@@ -160,11 +161,11 @@ const torStatusEl = el('tor-status');
const torProgressEl = el('tor-progress');
const torProgressBar = el('tor-progress-bar');
-function showTorStatus(kind, html) {
+function showNetStatus(kind, html) {
torStatusEl.className = 'tor-status ' + kind;
torStatusEl.innerHTML = html;
}
-function hideTorStatus() {
+function hideNetStatus() {
torStatusEl.className = 'tor-status hidden';
torStatusEl.textContent = '';
}
@@ -181,7 +182,7 @@ function hideTorProgress() {
chrome.runtime.onMessage.addListener((m) => {
if (m && m.type === 'tor-progress') {
showTorProgress(m.pct);
- showTorStatus('', `Connecting through Tor… ${Math.round(m.pct)}%`);
+ showNetStatus('', `Connecting through Tor… ${Math.round(m.pct)}%`);
}
});
function setTorButton(on) {
@@ -240,7 +241,7 @@ async function toggleTor() {
torBtn.classList.add('busy');
torBtn.disabled = true;
if (turningOn) {
- showTorStatus('', 'Connecting through Tor… (the first run can take up to a minute)');
+ showNetStatus('', 'Connecting through Tor… (the first run can take up to a minute)');
showTorProgress(0);
}
try {
@@ -250,41 +251,107 @@ async function toggleTor() {
'Tor Browser.';
if (!turningOn) {
setTorButton(false);
- hideTorStatus();
+ hideNetStatus();
} else if (res && res.enabled && res.check && res.check.ok && res.check.isTor) {
setTorButton(true);
- showTorStatus('ok', `Connected via Tor · exit IP ${safeIp(res.check.ip)}. ${torBrowserNote}`);
+ showNetStatus('ok', `Connected via Tor · exit IP ${safeIp(res.check.ip)}. ${torBrowserNote}`);
} else if (res && res.enabled) {
// Tor started and we're routing through it; the exit-IP probe just didn't
// confirm in time (a fresh circuit can be slow). Stay ON, don't alarm.
setTorButton(true);
- showTorStatus('ok', `Tor is on — routing this session through Tor. ${torBrowserNote}`);
+ showNetStatus('ok', `Tor is on — routing this session through Tor. ${torBrowserNote}`);
} else {
// Background couldn't route. Explain why, in plain language.
setTorButton(false);
const err = res && res.started && res.started.error;
if (err === 'tor-starting') {
- showTorStatus('', 'Tor is still connecting — the first run downloads the Tor network and can take a minute or two. Click 🧅 again in a few seconds; it’ll finish in the background.');
+ showNetStatus('', 'Tor is still connecting — the first run downloads the Tor network and can take a minute or two. Click 🧅 again in a few seconds; it’ll finish in the background.');
} else if (err === 'tor-not-installed') {
- showTorStatus('warn', 'Tor isn’t installed yet. Run tron tor once (it installs Tor automatically), then try again.');
+ showNetStatus('warn', 'Tor isn’t installed yet. Run tron tor once (it installs Tor automatically), then try again.');
} else if (err === 'unreachable') {
- showTorStatus('warn', 'Couldn’t reach the Tor helper. Restart TronBrowser and try again, or run tron tor.');
+ showNetStatus('warn', 'Couldn’t reach the Tor helper. Restart TronBrowser and try again, or run tron tor.');
} else {
- showTorStatus('warn', 'Tor couldn’t start. See ~/.tronbrowser/tor-helper.log for the reason.');
+ showNetStatus('warn', 'Tor couldn’t start. See ~/.tronbrowser/tor-helper.log for the reason.');
}
}
} catch (e) {
setTorButton(false);
- showTorStatus('warn', 'Could not toggle Tor: ' + ((e && e.message) || e));
+ showNetStatus('warn', 'Could not toggle Tor: ' + ((e && e.message) || e));
} finally {
hideTorProgress();
torBtn.classList.remove('busy');
torBtn.disabled = false;
+ refreshPitState(); // turning Tor on takes the pit down
}
}
torBtn.addEventListener('click', toggleTor);
+// --- Pit toggle ----------------------------------------------------------
+// Resolves Moshpit names (.eggs, .moshpit, …) in this session only — the
+// background points a PAC at the helper's local resolver for hosts the system
+// resolver has no answer for. Nothing on the machine changes and no root is
+// needed; for every app on the box, `moshcode dns enable` is still the answer.
+const pitBtn = el('pit');
+function setPitButton(on) {
+ pitBtn.setAttribute('aria-pressed', on ? 'true' : 'false');
+ pitBtn.textContent = on ? '🤘 Pit ON' : '🤘 Pit';
+}
+// The probe name comes from the helper; still strip to hostname chars before injecting.
+function safeHost(h) { return String(h || '?').replace(/[^0-9a-zA-Z.-]/g, ''); }
+
+async function refreshPitState() {
+ try {
+ const res = await chrome.runtime.sendMessage({ type: 'pit-status' });
+ setPitButton(!!(res && res.enabled));
+ } catch (_) { /* background may be asleep */ }
+}
+
+async function togglePit() {
+ const turningOn = pitBtn.getAttribute('aria-pressed') !== 'true';
+ pitBtn.classList.add('busy');
+ pitBtn.disabled = true;
+ if (turningOn) showNetStatus('', 'Starting the pit resolver…');
+ try {
+ const res = await chrome.runtime.sendMessage({ type: 'pit-set', on: turningOn });
+ const tip =
+ 'Clearnet names are untouched. https:// on a pit name needs ' +
+ 'moshcode dns enable once, for the certificate.';
+ if (!turningOn) {
+ setPitButton(false);
+ hideNetStatus();
+ } else if (res && res.enabled && res.check && res.check.ok) {
+ setPitButton(true);
+ showNetStatus('ok', `Pit is on — Moshpit names resolve in this session (${safeHost(res.check.name)} → ${safeIp(res.check.ip)}). ${tip}`);
+ } else if (res && res.enabled) {
+ // The resolver is up but the Moshpit DoH endpoint didn't answer the probe
+ // (offline, or slow). Stay ON — names resolve as soon as it is reachable.
+ setPitButton(true);
+ showNetStatus('ok', `Pit is on, but the Moshpit resolver didn’t answer yet — names resolve once dns.moshcode.sh is reachable. ${tip}`);
+ } else {
+ setPitButton(false);
+ const err = res && res.error;
+ if (err === 'tor-on') {
+ showNetStatus('warn', 'Turn 🧅 Tor off first — Moshpit names can’t resolve through Tor, and checking them would leak lookups outside it.');
+ } else if (err === 'unreachable') {
+ showNetStatus('warn', 'Couldn’t reach the TronBrowser helper. Restart TronBrowser and try again, or run tron upgrade.');
+ } else if (err === 'pit-port-busy') {
+ showNetStatus('warn', `Port ${PIT_SOCKS_PORT} on this machine is taken by another program, so the pit resolver couldn’t start.`);
+ } else {
+ showNetStatus('warn', 'The pit resolver couldn’t start. See ~/.tronbrowser/tor-helper.log for the reason.');
+ }
+ }
+ } catch (e) {
+ setPitButton(false);
+ showNetStatus('warn', 'Could not toggle the pit: ' + ((e && e.message) || e));
+ } finally {
+ pitBtn.classList.remove('busy');
+ pitBtn.disabled = false;
+ }
+}
+
+pitBtn.addEventListener('click', togglePit);
+
// The ? next to the Tor button opens the same explainer on demand (info mode —
// never enables Tor, whatever button closes it).
el('tor-info').addEventListener('click', () => {
@@ -293,4 +360,4 @@ el('tor-info').addEventListener('click', () => {
torWarnDlg.showModal();
});
-(async () => { await loadConfig(); await consumePendingQuery(); await refreshTorState(); })();
+(async () => { await loadConfig(); await consumePendingQuery(); await refreshTorState(); await refreshPitState(); })();
diff --git a/apps/desktop/launcher/tron-tor-helper b/apps/desktop/launcher/tron-tor-helper
index 86179d4..36501f6 100755
--- a/apps/desktop/launcher/tron-tor-helper
+++ b/apps/desktop/launcher/tron-tor-helper
@@ -1,8 +1,10 @@
#!/usr/bin/env python3
-"""TronBrowser Tor control helper.
+"""TronBrowser network helper: the 🧅 Tor and 🤘 Pit toggles' back end.
A loopback-only HTTP control endpoint the AI-sidebar 🧅 Tor toggle calls to
-start/stop a local Tor daemon ON DEMAND. The launcher starts this helper at
+start/stop a local Tor daemon ON DEMAND, and the 🤘 Pit toggle calls to
+start/stop a local SOCKS5 resolver for Moshpit names (see the Moshpit section
+below). The launcher starts this helper at
browser launch (both the desktop app entry and `tron` go through the same
shim), but it makes NO network connection until the toggle asks it to /start —
so nobody connects to Tor unless they actually turn it on.
@@ -11,22 +13,32 @@ This is convenience routing, NOT Tor-Browser-grade anonymity. See
docs/tor-onion-mode.md.
Endpoints (POST/GET on 127.0.0.1):
- /start start tor, block until bootstrapped → {"ready": true} or error
- /stop stop tor → {"stopped": true}
- /status {"running": bool, "ready": bool, "torInstalled": bool}
+ /start start tor, block until bootstrapped → {"ready": true} or error
+ /stop stop tor → {"stopped": true}
+ /status {"running": bool, "ready": bool, "torInstalled": bool, "pit": {…}}
+ /pit/start start the Moshpit SOCKS5 resolver → {"started": true, "check": {…}}
+ /pit/stop stop it → {"stopped": true}
+ /pit/status {"running": bool, "port": int, "doh": url}
Single-instance: binds a fixed loopback port; a second copy exits cleanly when
the port is taken, so the launcher can fire-and-forget it every launch.
"""
+import base64
import glob
import json
import os
+import random
import re
+import select
import shutil
import signal
+import socket
+import struct
import subprocess
import sys
import threading
+import time
+import urllib.request
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
HOST = "127.0.0.1"
@@ -37,7 +49,7 @@ BUNDLED_DIR = os.environ.get("TRON_TOR_BIN_DIR", "")
PIDFILE = os.environ.get("TRON_TOR_PIDFILE", "")
# Bumped whenever the helper protocol/behaviour changes; the launcher kills a
# stale helper so the current version always runs.
-HELPER_VERSION = "3.2.8"
+HELPER_VERSION = "3.3.0"
_lock = threading.Lock()
_proc = None # the running tor subprocess (or None)
_ready = False # True once tor reported Bootstrapped 100%
@@ -198,9 +210,313 @@ def status():
"progress": _progress,
"error": _error,
"torInstalled": tor_binary() is not None,
+ "pit": pit_status(),
"version": HELPER_VERSION}
+# --- Moshpit names (🤘 Pit toggle) ------------------------------------------
+# Moshpit endings (.eggs, .moshpit, .yeah, …) live outside the ICANN root, so the
+# system resolver has nowhere to look them up. `moshcode dns enable` fixes that
+# for the whole machine, but it needs root and rewrites the resolver config.
+# This is the no-root, this-browser-only alternative, built like the Tor toggle:
+# a loopback SOCKS5 proxy that resolves names through the Moshpit DNS-over-HTTPS
+# resolver and connects to whatever it answers. The extension's PAC script sends
+# only hosts the system resolver has NO answer for here (clearnet wins, the pit
+# is the fallback — the house policy), so ordinary sites never pass through it.
+# Nothing on the machine changes, and it is off again on the next launch.
+PIT_SOCKS_PORT = int(os.environ.get("TRON_PIT_SOCKS_PORT", "9081"))
+PIT_DOH_URL = os.environ.get("TRON_PIT_DOH_URL", "https://dns.moshcode.sh/dns-query")
+# A name that must resolve while the pit is up: the Pit's own front door for
+# .eggs. `mosh.` is reserved by the registry, so nobody can squat it.
+PIT_PROBE_NAME = os.environ.get("TRON_PIT_PROBE_NAME", "mosh.eggs")
+PIT_CONNECT_TIMEOUT = 15
+PIT_IDLE_TIMEOUT = 600 # seconds without a byte either way before a relay closes
+_pit = None # the running PitSocks server (or None)
+_pit_lock = threading.Lock()
+_pit_cache = {} # name -> (expires_at, [ips])
+_pit_cache_lock = threading.Lock()
+
+
+def _dns_query(name, qtype=1):
+ """Wire-format DNS query for `name` (RFC 1035): (id, bytes)."""
+ qid = random.randint(0, 0xFFFF)
+ q = struct.pack(">HHHHHH", qid, 0x0100, 1, 0, 0, 0) # RD=1, one question
+ for label in name.rstrip(".").split("."):
+ raw = label.encode("idna")
+ if not raw or len(raw) > 63:
+ raise ValueError("bad label in %r" % name)
+ q += bytes([len(raw)]) + raw
+ q += b"\x00" + struct.pack(">HH", qtype, 1) # QTYPE, QCLASS=IN
+ return qid, q
+
+
+def _skip_name(buf, pos):
+ """Index just past the (possibly compressed) name starting at `pos`."""
+ while True:
+ if pos >= len(buf):
+ raise ValueError("truncated name")
+ length = buf[pos]
+ if length == 0:
+ return pos + 1
+ if length & 0xC0 == 0xC0: # compression pointer: two bytes, ends the name
+ return pos + 2
+ pos += 1 + length
+
+
+def _parse_a_records(buf, qid):
+ """(rcode, [ipv4 strings], min ttl) from a DNS response — A records only."""
+ if len(buf) < 12:
+ raise ValueError("short response")
+ rid, flags, qdcount, ancount, _ns, _ar = struct.unpack(">HHHHHH", buf[:12])
+ if rid != qid:
+ raise ValueError("transaction id mismatch")
+ pos = 12
+ for _ in range(qdcount):
+ pos = _skip_name(buf, pos) + 4 # QTYPE + QCLASS
+ ips, ttl = [], None
+ for _ in range(ancount):
+ pos = _skip_name(buf, pos)
+ rtype, rclass, rttl, rdlen = struct.unpack(">HHIH", buf[pos:pos + 10])
+ pos += 10
+ rdata = buf[pos:pos + rdlen]
+ pos += rdlen
+ if rtype == 1 and rclass == 1 and rdlen == 4:
+ ips.append(socket.inet_ntoa(rdata))
+ ttl = rttl if ttl is None else min(ttl, rttl)
+ return flags & 0xF, ips, (60 if ttl is None else ttl)
+
+
+def doh_resolve(name, timeout=6.0):
+ """A records for `name` from the Moshpit DoH resolver (RFC 8484 GET).
+ Returns (ips, ttl, rcode); raises on transport or malformed answers."""
+ qid, q = _dns_query(name)
+ url = "%s?dns=%s" % (PIT_DOH_URL, base64.urlsafe_b64encode(q).rstrip(b"=").decode("ascii"))
+ req = urllib.request.Request(url, headers={
+ "Accept": "application/dns-message",
+ "User-Agent": "tron-pit-helper/%s" % HELPER_VERSION,
+ })
+ with urllib.request.urlopen(req, timeout=timeout) as res:
+ body = res.read(65535)
+ rcode, ips, ttl = _parse_a_records(body, qid)
+ return ips, ttl, rcode
+
+
+def pit_resolve(name):
+ """IPv4 addresses for a host: the cached DoH answer, else the system resolver
+ as a last resort (a clearnet name should never arrive here, but when one does
+ it must still work). [] when nobody knows the name; negatives are cached
+ briefly so a page full of broken links does not hammer the resolver."""
+ key = name.lower().rstrip(".")
+ now = time.time()
+ with _pit_cache_lock:
+ hit = _pit_cache.get(key)
+ if hit and hit[0] > now:
+ return hit[1]
+ ips, ttl = [], 60
+ try:
+ ips, ttl, _rcode = doh_resolve(key)
+ except Exception as exc: # noqa: BLE001 — any failure means "try the OS"
+ log("pit: DoH lookup of %s failed: %s" % (key, exc))
+ if not ips:
+ try:
+ ips = sorted({ai[4][0] for ai in socket.getaddrinfo(key, None, socket.AF_INET, socket.SOCK_STREAM)})
+ except OSError:
+ ips = []
+ with _pit_cache_lock:
+ _pit_cache[key] = (now + (max(30, min(int(ttl), 3600)) if ips else 30), ips)
+ return ips
+
+
+def _recv_exact(sock, n):
+ buf = b""
+ while len(buf) < n:
+ chunk = sock.recv(n - len(buf))
+ if not chunk:
+ raise ConnectionError("client closed")
+ buf += chunk
+ return buf
+
+
+def _socks_reply(conn, rep):
+ # BND.ADDR/BND.PORT are meaningless for CONNECT through a relay; Chromium
+ # ignores them, so always answer 0.0.0.0:0.
+ try:
+ conn.sendall(b"\x05" + bytes([rep]) + b"\x00\x01" + b"\x00\x00\x00\x00" + b"\x00\x00")
+ except OSError:
+ pass
+
+
+def _pit_relay(a, b):
+ """Shuttle bytes both ways until either side closes or goes idle."""
+ pair = [a, b]
+ try:
+ while True:
+ readable, _w, broken = select.select(pair, [], pair, PIT_IDLE_TIMEOUT)
+ if broken or not readable:
+ return
+ for s in readable:
+ data = s.recv(65536)
+ if not data:
+ return
+ (b if s is a else a).sendall(data)
+ except OSError:
+ return
+ finally:
+ for s in pair:
+ try:
+ s.close()
+ except OSError:
+ pass
+
+
+def _pit_serve(conn):
+ """One SOCKS5 client: no-auth handshake, CONNECT, resolve, relay."""
+ conn.settimeout(PIT_CONNECT_TIMEOUT)
+ upstream = None
+ try:
+ ver, nmethods = _recv_exact(conn, 2)
+ if ver != 5:
+ return
+ _recv_exact(conn, nmethods)
+ conn.sendall(b"\x05\x00") # no authentication required
+ ver, cmd, _rsv, atyp = _recv_exact(conn, 4)
+ if ver != 5:
+ return
+ if atyp == 1:
+ host = socket.inet_ntoa(_recv_exact(conn, 4))
+ elif atyp == 3:
+ host = _recv_exact(conn, _recv_exact(conn, 1)[0]).decode("ascii", "replace")
+ elif atyp == 4:
+ host = socket.inet_ntop(socket.AF_INET6, _recv_exact(conn, 16))
+ else:
+ _socks_reply(conn, 0x08) # address type not supported
+ return
+ port = struct.unpack(">H", _recv_exact(conn, 2))[0]
+ if cmd != 1:
+ _socks_reply(conn, 0x07) # only CONNECT; no BIND / UDP ASSOCIATE
+ return
+ ips = pit_resolve(host) if atyp == 3 else [host]
+ if not ips:
+ log("pit: no address for %s" % host)
+ _socks_reply(conn, 0x04) # host unreachable
+ return
+ rep = 0x05
+ for ip in ips[:3]:
+ try:
+ upstream = socket.create_connection((ip, port), timeout=PIT_CONNECT_TIMEOUT)
+ break
+ except OSError as exc:
+ rep = 0x05 if isinstance(exc, ConnectionRefusedError) else 0x04
+ if upstream is None:
+ log("pit: could not connect to %s:%d via %s" % (host, port, ", ".join(ips[:3])))
+ _socks_reply(conn, rep)
+ return
+ upstream.settimeout(None)
+ conn.settimeout(None)
+ _socks_reply(conn, 0x00)
+ _pit_relay(conn, upstream)
+ except (OSError, ConnectionError, ValueError, struct.error, IndexError):
+ pass
+ finally:
+ for s in (conn, upstream):
+ if s is not None:
+ try:
+ s.close()
+ except OSError:
+ pass
+
+
+class PitSocks(threading.Thread):
+ """Loopback SOCKS5 listener; one daemon thread per client."""
+
+ def __init__(self, port):
+ super().__init__(daemon=True, name="pit-socks")
+ self.port = port
+ self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+ self.sock.bind((HOST, port))
+ self.sock.listen(64)
+ self._stopping = threading.Event()
+
+ def run(self):
+ # A blocked accept() is not woken by close() from another thread — the
+ # port would stay bound with nobody listening. Poll the stop flag instead;
+ # accepted sockets come back blocking regardless of this timeout.
+ self.sock.settimeout(0.5)
+ while not self._stopping.is_set():
+ try:
+ conn, _addr = self.sock.accept()
+ except socket.timeout:
+ continue
+ except OSError:
+ break # shut down by stop()
+ threading.Thread(target=_pit_serve, args=(conn,), daemon=True).start()
+ try:
+ self.sock.close()
+ except OSError:
+ pass
+
+ def stop(self):
+ """Stop listening and wait for the port to be released, so a caller that
+ gets {"stopped": true} can rely on it being closed."""
+ self._stopping.set()
+ try:
+ self.sock.shutdown(socket.SHUT_RDWR) # wakes accept() at once on Linux
+ except OSError:
+ pass
+ self.join(timeout=3)
+ try:
+ self.sock.close()
+ except OSError:
+ pass
+
+
+def start_pit():
+ """Ensure the resolver is listening. Returns (ok, error)."""
+ global _pit
+ with _pit_lock:
+ if _pit is not None and _pit.is_alive():
+ return True, None
+ try:
+ srv = PitSocks(PIT_SOCKS_PORT)
+ except OSError as exc:
+ log("pit: cannot bind %s:%d: %s" % (HOST, PIT_SOCKS_PORT, exc))
+ return False, "pit-port-busy"
+ srv.start()
+ _pit = srv
+ log("pit: SOCKS5 resolver listening on %s:%d (DoH %s)" % (HOST, PIT_SOCKS_PORT, PIT_DOH_URL))
+ return True, None
+
+
+def stop_pit():
+ global _pit
+ with _pit_lock:
+ srv, _pit = _pit, None
+ if srv is not None:
+ srv.stop()
+ log("pit: stopped")
+ with _pit_cache_lock:
+ _pit_cache.clear()
+
+
+def pit_probe():
+ """Resolve a name that must exist so the toggle can report whether names
+ will actually resolve, not merely that a port is open."""
+ try:
+ ips, _ttl, rcode = doh_resolve(PIT_PROBE_NAME)
+ if ips:
+ return {"ok": True, "name": PIT_PROBE_NAME, "ip": ips[0]}
+ return {"ok": False, "name": PIT_PROBE_NAME, "error": "no-answer (rcode %d)" % rcode}
+ except Exception as exc: # noqa: BLE001 — reported to the user, never raised
+ return {"ok": False, "name": PIT_PROBE_NAME, "error": str(exc)}
+
+
+def pit_status():
+ with _pit_lock:
+ running = _pit is not None and _pit.is_alive()
+ return {"running": running, "port": PIT_SOCKS_PORT, "doh": PIT_DOH_URL, "version": HELPER_VERSION}
+
+
class Handler(BaseHTTPRequestHandler):
def _send(self, code, obj):
body = json.dumps(obj).encode("utf-8")
@@ -228,6 +544,20 @@ class Handler(BaseHTTPRequestHandler):
self._send(200, {"stopped": True})
elif path in ("/status", "/"):
self._send(200, status())
+ elif path == "/pit/start":
+ ok, err = start_pit()
+ st = pit_status()
+ st["started"] = ok
+ if err:
+ st["error"] = err
+ else:
+ st["check"] = pit_probe()
+ self._send(200 if ok else 503, st)
+ elif path == "/pit/stop":
+ stop_pit()
+ self._send(200, {"stopped": True})
+ elif path == "/pit/status":
+ self._send(200, pit_status())
else:
self._send(404, {"error": "not-found"})
@@ -243,6 +573,7 @@ class Handler(BaseHTTPRequestHandler):
def _shutdown(*_):
stop_tor()
+ stop_pit()
_remove_pidfile()
sys.exit(0)
@@ -275,11 +606,13 @@ def main():
# launcher kills a stale helper before us, so this is rare.)
return
_write_pidfile()
- log("listening on %s:%d v%s (tor: %s)" % (HOST, PORT, HELPER_VERSION, tor_binary() or "NOT FOUND"))
+ log("listening on %s:%d v%s (tor: %s; pit resolver on :%d when toggled)"
+ % (HOST, PORT, HELPER_VERSION, tor_binary() or "NOT FOUND", PIT_SOCKS_PORT))
try:
server.serve_forever()
finally:
stop_tor()
+ stop_pit()
_remove_pidfile()
diff --git a/apps/desktop/launcher/tronbrowser b/apps/desktop/launcher/tronbrowser
index ea692a1..cd83ee3 100755
--- a/apps/desktop/launcher/tronbrowser
+++ b/apps/desktop/launcher/tronbrowser
@@ -150,9 +150,10 @@ if [ "$TOR" = "1" ]; then
rm -rf "$DATA"
fi
-# On-demand Tor control helper for the in-browser 🧅 Tor toggle (AI sidebar).
-# Loopback-only; it makes NO network connection until the toggle asks it to
-# start Tor — so nobody connects to Tor unless they turn it on. Fire-and-forget
+# On-demand network helper for the in-browser 🧅 Tor and 🤘 Pit toggles (AI
+# sidebar). Loopback-only; it makes NO network connection until a toggle asks
+# it to start Tor or the Moshpit resolver — so nobody connects to either unless
+# they turn it on. Fire-and-forget
# + idempotent (a second copy exits when the port is taken), so both the desktop
# app entry and `tron` (which share this shim) get it. Best-effort: needs
# python3, and skipped in --tor mode (that path runs its own Tor).
@@ -168,13 +169,13 @@ if [ "$TOR" != "1" ]; then
elif [ -x "$DIR/tor-bin/tor" ]; then _torbin="$DIR/tor-bin/tor (auto-installed)";
elif command -v tor >/dev/null 2>&1; then _torbin="$(command -v tor)";
else _torbin="NOT FOUND — run 'tron tor' to install it"; fi
- echo "TronBrowser: Tor toggle ready — helper control on 127.0.0.1:9061, Tor SOCKS on 127.0.0.1:$TOR_SOCKS_PORT (tor: $_torbin). Click 🧅 Tor in the AI sidebar. Log: $DATA/tor-helper.log" >&2
+ echo "TronBrowser: Tor + Pit toggles ready — helper control on 127.0.0.1:9061, Tor SOCKS on 127.0.0.1:$TOR_SOCKS_PORT (tor: $_torbin), Moshpit resolver on 127.0.0.1:9081. Click 🧅 Tor or 🤘 Pit in the AI sidebar. Log: $DATA/tor-helper.log" >&2
# The helper persists across launches, so an OLD one (from before an upgrade)
# would keep answering the toggle with stale code. Replace it ONLY when the
# running helper isn't this version — otherwise leave a healthy current
# helper alone (don't drop an active Tor session). All backgrounded so the
# kill+settle never holds up the browser launch.
- HELPER_VERSION=3.2.8
+ HELPER_VERSION=3.3.0
(
_pf="$DATA/tor-helper.pid"
_rv="$(curl -fsS --max-time 1 http://127.0.0.1:9061/status 2>/dev/null | sed -n 's/.*"version"[^"]*"\([^"]*\)".*/\1/p')"
diff --git a/apps/desktop/scripts/build-release.sh b/apps/desktop/scripts/build-release.sh
index 97cdab2..cefe93b 100755
--- a/apps/desktop/scripts/build-release.sh
+++ b/apps/desktop/scripts/build-release.sh
@@ -68,8 +68,8 @@ stage() { # dest dir
mkdir -p "$s/extensions"
install -m 0755 "$DESKTOP/launcher/tronbrowser" "$s/tronbrowser"
cp "$DESKTOP/launcher/tronbrowser.cmd" "$s/tronbrowser.cmd"
- # On-demand Tor control helper for the in-browser 🧅 Tor toggle (the launcher
- # starts it; it starts Tor only when the toggle asks).
+ # On-demand network helper for the in-browser 🧅 Tor and 🤘 Pit toggles (the
+ # launcher starts it; it starts Tor / the Moshpit resolver only when asked).
install -m 0755 "$DESKTOP/launcher/tron-tor-helper" "$s/tron-tor-helper"
# Repoints installed-web-app desktop icons at the launcher. The shim runs it
# on every start (the engine rewrites those files behind us); `tron pwa` is
diff --git a/docs/moshpit-pit-toggle.md b/docs/moshpit-pit-toggle.md
new file mode 100644
index 0000000..ded06c0
--- /dev/null
+++ b/docs/moshpit-pit-toggle.md
@@ -0,0 +1,115 @@
+# 🤘 Pit toggle — Moshpit names for one browser session
+
+**Status:** shipped with the AI-sidebar extension + `tron-tor-helper` 3.3.0
+**Owner:** desktop (`apps/desktop`)
+**Scope:** resolve Moshpit names in the running browser with one click. Not a
+replacement for `moshcode dns enable`, which does it for the whole machine.
+
+---
+
+## What it is
+
+Moshpit endings (`.eggs`, `.moshpit`, `.yeah`, thousands more) live outside the
+ICANN root, so the system resolver has nowhere to look them up. There are two
+ways to make them work, and the settings page says so:
+
+| | `moshcode dns enable` | 🤘 Pit toggle |
+| --- | --- | --- |
+| Scope | every application on the machine | this browser session |
+| Needs root | yes (rewrites the resolver config, installs a local CA) | no |
+| Survives restart | yes | no — off again on every launch, like 🧅 Tor |
+| `https://` on a pit name | works (pinned proxy + local CA) | warns, unless `moshcode dns enable` has installed the CA |
+| Clearnet names | forwarded to public resolvers | never touched |
+
+The toggle is for the laptop where DNS is not yours to change, or the first
+five minutes before you have run `moshcode dns enable`.
+
+## How it works
+
+It is built exactly like the 🧅 Tor toggle:
+
+1. **The helper.** The launcher already runs a loopback control helper
+ (`apps/desktop/launcher/tron-tor-helper`, `127.0.0.1:9061`) at every browser
+ launch. It now also serves `/pit/start`, `/pit/stop` and `/pit/status`.
+ `/pit/start` binds a **SOCKS5 resolver on `127.0.0.1:9081`** and probes the
+ Moshpit DNS-over-HTTPS resolver (`https://dns.moshcode.sh/dns-query`) with
+ `mosh.eggs`, a name the registry reserves, so the toggle can say whether names
+ will actually resolve and not merely that a port is open. Like Tor, **nothing
+ listens and nothing is contacted until the toggle asks**.
+2. **The resolver.** For each `CONNECT`, the SOCKS server asks the DoH resolver
+ for the name's A records (RFC 8484 GET, answers cached for their TTL, negatives
+ for 30 s), falls back to the system resolver if that fails, connects to the
+ answer and relays bytes. TLS passes through untouched. Only `CONNECT` over TCP
+ is implemented; the browser never asks a SOCKS proxy for UDP.
+3. **The PAC.** The extension (`apps/desktop/extensions/ai-sidebar/pit-proxy.js`)
+ installs a `pac_script` proxy config whose `FindProxyForURL` calls
+ `dnsResolve(host)`. **Anything the system resolver can answer goes `DIRECT`,
+ untouched.** Only a host it has no answer for is sent to the pit resolver.
+ Loopback, single-label intranet names and IP literals always go `DIRECT`.
+
+That last rule is the point. Real TLDs (`.io`, `.dev`, `.sh`, …) have been
+claimed as Moshpit endings too, so an ending list cannot say whether a host is a
+Moshpit name. The house policy is **clearnet wins, the pit is the fallback**: a
+resolver that silently redirected a domain which already works would be
+indistinguishable from a hijack. `dnsResolve` applies that policy per host with
+no ending list to fetch, cache or age out.
+
+The PAC is not `mandatory`: if it ever fails to evaluate, Chromium falls back to
+`DIRECT` and ordinary browsing keeps working.
+
+## Tor and the pit are exclusive
+
+The pit's PAC asks the **system** resolver about every host. With Tor on, that
+would leak every lookup outside Tor, so:
+
+- turning 🧅 Tor on takes the pit down first (the background does it, the
+ sidebar refreshes the button);
+- turning 🤘 Pit on while Tor is on is refused with a plain-language reason.
+
+## What you see
+
+- The button reads **🤘 Pit ON** in moshcode's acid green, the toolbar icon gets
+ a **PIT** badge, and the status strip shows the probe:
+ `Pit is on — Moshpit names resolve in this session (mosh.eggs → 67.205.189.229)`.
+- Test it by opening `http://mosh.eggs` — the Pit's own front door for `.eggs`.
+- If the DoH resolver did not answer the probe (offline, slow), the pit stays
+ **on** and says so; names resolve as soon as it is reachable.
+- Failures the sidebar explains: the helper is not running (`tron upgrade`,
+ restart), port 9081 is taken, Tor is on.
+
+## Files
+
+| File | Role |
+| --- | --- |
+| `apps/desktop/launcher/tron-tor-helper` | `/pit/*` routes, the SOCKS5 resolver, the DoH client |
+| `apps/desktop/launcher/tronbrowser` | starts the helper; `HELPER_VERSION` must match the helper's so a stale one is replaced |
+| `apps/desktop/extensions/ai-sidebar/pit-proxy.js` | the PAC + proxy config (pure, tested in `pit-proxy.test.js`) |
+| `apps/desktop/extensions/ai-sidebar/background.js` | `pit-set` / `pit-status` messages, badge, session-scoped state |
+| `apps/desktop/extensions/ai-sidebar/sidepanel.*` | the button and its status copy |
+
+Environment knobs on the helper: `TRON_PIT_SOCKS_PORT` (9081),
+`TRON_PIT_DOH_URL`, `TRON_PIT_PROBE_NAME`.
+
+## Testing the helper by hand
+
+```sh
+TRON_TOR_HELPER_PORT=19061 TRON_PIT_SOCKS_PORT=19081 python3 apps/desktop/launcher/tron-tor-helper &
+curl -X POST http://127.0.0.1:19061/pit/start
+curl --socks5-hostname 127.0.0.1:19081 -I http://mosh.eggs/ # 302 → pit.moshcode.sh
+curl --socks5-hostname 127.0.0.1:19081 -I https://example.com/ # clearnet relays too
+curl -X POST http://127.0.0.1:19061/pit/stop
+```
+
+## Not in this version
+
+- **`https://` on pit names without the CA.** The pit page documents it: no
+ public CA issues for a namespace outside the ICANN root. `moshcode dns enable`
+ installs the Moshpit CA and the launcher mirrors it into Chromium's trust
+ store on every start, so the two features compose.
+- **"Moshpit wins."** The resolvers' `MOSHPIT_RESOLVE_MODE=moshpit` lets a
+ registered name override a clearnet one. The toggle only implements the
+ default `fallback` policy.
+- **Persisting across launches.** Mirrors Tor deliberately; a setting to keep
+ the pit on would be a small follow-up.
+- **Windows.** The `.cmd` shim does not start the helper, so neither toggle
+ works there yet.
diff --git a/docs/tor-onion-mode.md b/docs/tor-onion-mode.md
index 4d89b1e..b0f8653 100644
--- a/docs/tor-onion-mode.md
+++ b/docs/tor-onion-mode.md
@@ -35,7 +35,11 @@ always-on Tor on everyone, the launcher runs a tiny **control helper**
(`launcher/tron-tor-helper`, loopback-only on `127.0.0.1:9061`) at every browser
launch — both the desktop app entry and the `tron` CLI go through the same shim,
so both get it. **The helper makes no network connection until asked:** nobody
-connects to Tor unless they flip the toggle on.
+connects to Tor unless they flip the toggle on. (The same helper backs the
+**🤘 Pit** toggle — a loopback resolver for Moshpit names, see
+[`moshpit-pit-toggle.md`](moshpit-pit-toggle.md). The two are exclusive: turning
+Tor on takes the pit down, because the pit's PAC asks the system resolver about
+every host, which under Tor would leak lookups outside it.)
Flow when you click 🧅 Tor: