Skip to content

Commit 1a5b158

Browse files
ralyodioclaude
andcommitted
feat(extension): stop resolving Moshpit names in the browser
Name resolution belongs at the operating system. `moshcode dns enable` sets it up once per machine and every application resolves Moshpit names — curl, a terminal, another browser — instead of only this one. Doing it here made the namespace a property of the browser rather than of the machine, which is both the wrong layer and a worse experience: a name that worked in a tab and nowhere else. It also cost every navigation. `webNavigation.onBeforeNavigate` was registered with no URL filter, so every top-level navigation woke the MV3 service worker, which re-imported background.js -> moshpit.js -> tlds.js -> tld-data.js and rebuilt the IANA set before deciding, almost always, to do nothing. The classification itself is O(1); the wake-up is not, and it landed on every page load whether or not a Moshpit name was involved. With the extension loaded the browser was slow; without it, fast. Removed the two navigation hooks, the resolution modules and the TLD data they consulted, the options-page settings, the reference implementation in apps/desktop/src, the update-tlds chore that existed to regenerate the data, and the @moshcoder/moshpit-resolve devDependency. The `webNavigation` permission goes with them — those hooks were its only users. That is the part worth keeping: an extension that no longer watches every navigation cannot slow one down, and no longer asks for permission to see them. The Tor bypass list drops to loopback. The pit's hosts were exempted so a cold circuit could still answer a registry lookup before navigation; with no lookup, nothing needs to skip the proxy, and everything else goes through it. Settings now point at `moshcode dns enable` rather than offering a mode. Verified: no dangling imports across the extension, the linux archive builds with no moshpit/tlds files staged, lint clean, 68 tests pass (the 101 removed were the deleted modules' own). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent bfe9cb2 commit 1a5b158

20 files changed

Lines changed: 17 additions & 3386 deletions

‎.github/workflows/release.yml‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,8 +90,8 @@ jobs:
9090
Copy-Item -Recurse apps/desktop/extensions/ai-sidebar "$stage/extensions/ai-sidebar"
9191
# Same wholesale-copy problem build-release.sh has: the vitest files
9292
# next to the extension sources ride along into the zip. Chrome never
93-
# loads them, and moshpit-drift.test.js imports a devDependency that
94-
# cannot resolve from an unbundled extension. Drop them.
93+
# loads them, and they import vitest, which cannot resolve from an
94+
# unbundled extension. Drop them.
9595
Get-ChildItem "$stage/extensions/ai-sidebar" -Filter *.test.js -Recurse | Remove-Item -Force
9696
Copy-Item LICENSE "$stage/LICENSE"
9797
"v${{ needs.create-release.outputs.version }}" | Out-File -Encoding ascii "$stage/VERSION"

‎apps/desktop/extensions/ai-sidebar/background.js‎

Lines changed: 5 additions & 101 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,3 @@
1-
import { destinationFor, moshpitBypassHosts, moshpitConfig } from './moshpit.js';
2-
import { routeForDnsFailure, routeForNavigation, territoryOf } from './moshpit-routing.js';
3-
41
// Open the AI side panel when the toolbar action is clicked.
52
chrome.sidePanel
63
.setPanelBehavior({ openPanelOnActionClick: true })
@@ -169,22 +166,17 @@ async function stopTorViaHelper() {
169166
} catch (_) { /* helper not running — nothing to stop */ }
170167
}
171168

172-
function torProxyConfig(port, pitHosts = []) {
169+
function torProxyConfig(port) {
173170
return {
174171
mode: 'fixed_servers',
175172
rules: {
176173
// SOCKS5 → Chromium resolves DNS at the proxy, so .onion resolves inside
177174
// Tor and names never leak.
178175
singleProxy: { scheme: 'socks5', host: '127.0.0.1', port },
179176
// Loopback must bypass Tor: the SOCKS port + the control helper are on
180-
// 127.0.0.1, and Tor refuses to proxy private addresses anyway.
181-
//
182-
// The pit's own hosts bypass too. Resolution asks the registry a question
183-
// before a Moshpit navigation can complete, and a cold Tor circuit does
184-
// not answer inside the lookup budget — so routing them through Tor made
185-
// every Moshpit name fall back to clearnet, which looks exactly like the
186-
// namespace not existing. See moshpitBypassHosts for the privacy trade.
187-
bypassList: ['localhost', '127.0.0.1', '[::1]', ...pitHosts],
177+
// 127.0.0.1, and Tor refuses to proxy private addresses anyway. Nothing
178+
// else bypasses — every other host goes through the proxy.
179+
bypassList: ['localhost', '127.0.0.1', '[::1]'],
188180
},
189181
};
190182
}
@@ -198,11 +190,7 @@ async function setTorBadge(on) {
198190
}
199191

200192
async function enableTor() {
201-
// Read at enable time rather than cached: the options page can repoint the
202-
// registry at a self-hosted pit between one toggle and the next.
203-
let pitHosts = [];
204-
try { pitHosts = moshpitBypassHosts(await moshpitConfig()); } catch (_) { /* defaults are enough */ }
205-
await chrome.proxy.settings.set({ value: torProxyConfig(TOR_SOCKS_PORT, pitHosts), scope: 'regular' });
193+
await chrome.proxy.settings.set({ value: torProxyConfig(TOR_SOCKS_PORT), scope: 'regular' });
206194
// Stop WebRTC from leaking the real IP via non-proxied UDP.
207195
try {
208196
await chrome.privacy.network.webRTCIPHandlingPolicy.set({ value: 'disable_non_proxied_udp' });
@@ -301,87 +289,3 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
301289
}
302290
} catch (_) { /* best effort */ }
303291
})();
304-
305-
// --- Moshpit name resolution ---------------------------------------------
306-
// This is what makes the Moshpit settings on the options page actually do
307-
// something: until now they were written to storage and never read.
308-
//
309-
// Which namespace a hostname belongs to is decided by its ENDING, not by
310-
// whether DNS failed. See tlds.js for why: a resolver that hijacks NXDOMAIN
311-
// answers for `blue.eggs` too, so "DNS failed" is a signal we do not reliably
312-
// get, and on those connections the whole namespace silently stopped working.
313-
//
314-
// So there are two territories, and a hostname is in exactly one:
315-
//
316-
// An ending only Moshpit could own (`.eggs` — not IANA's, not reserved).
317-
// Clearnet cannot legitimately answer for it, so resolution runs in BOTH
318-
// modes and does not wait for a DNS error that may never come. This is the
319-
// path that a hijacking resolver used to swallow.
320-
//
321-
// A real or reserved ending (`.com`, `.onion`, `.local`). Ordinary browsing,
322-
// and the default mode never touches the registry for it — no round-trip,
323-
// no added latency, nothing on the wire. Only the opt-in 'moshpit' mode
324-
// consults the registry here, because only it lets a registered name
325-
// override a working clearnet domain, and that is what it costs.
326-
//
327-
// onErrorOccurred still backfills a real ending whose DNS genuinely failed —
328-
// that is an honest signal when we get it, and it is how a `.com` that nobody
329-
// registered can still fall through to Moshpit.
330-
//
331-
// No redirect loop: every destination we send a tab to (pit.moshcode.sh/n/…,
332-
// app.moshcode.sh/pit) has three labels, so parseRegistryName rejects it and
333-
// the hooks ignore it on the way back through.
334-
335-
const DNS_FAILED = new Set([
336-
'net::ERR_NAME_NOT_RESOLVED',
337-
'net::ERR_NAME_RESOLUTION_FAILED',
338-
]);
339-
340-
// The hostname of a top-level http(s) navigation. Whether it is ours to touch
341-
// is routeForNavigation's call, not this one's.
342-
function navigationHostname(url) {
343-
try {
344-
const u = new URL(url);
345-
if (u.protocol !== 'http:' && u.protocol !== 'https:') return '';
346-
return u.hostname;
347-
} catch {
348-
return '';
349-
}
350-
}
351-
352-
async function sendTabTo(tabId, url) {
353-
try {
354-
await chrome.tabs.update(tabId, { url });
355-
} catch (err) {
356-
console.warn('moshpit redirect:', err);
357-
}
358-
}
359-
360-
chrome.webNavigation?.onErrorOccurred.addListener(async (details) => {
361-
if (details.frameId !== 0) return; // top-level navigations only
362-
if (!DNS_FAILED.has(details.error)) return;
363-
const hostname = navigationHostname(details.url);
364-
const route = routeForDnsFailure(hostname);
365-
if (!route.resolve) return;
366-
const dest = await destinationFor(hostname, route.clearnetResolves);
367-
if (dest) await sendTabTo(details.tabId, dest);
368-
});
369-
370-
chrome.webNavigation?.onBeforeNavigate.addListener(async (details) => {
371-
if (details.frameId !== 0) return;
372-
const hostname = navigationHostname(details.url);
373-
374-
// The territory is decided from the hostname alone, so an ordinary navigation
375-
// to a real ending costs one Set lookup — no storage read, no registry call.
376-
// Only 'clearnet' has an answer that depends on the mode, so only it pays for
377-
// reading the mode.
378-
const territory = territoryOf(hostname);
379-
if (territory === 'none' || territory === 'reserved') return;
380-
const mode = territory === 'clearnet' ? (await moshpitConfig()).mode : 'clearnet';
381-
382-
const route = routeForNavigation(hostname, mode);
383-
if (!route.resolve) return;
384-
385-
const dest = await destinationFor(hostname, route.clearnetResolves);
386-
if (dest) await sendTabTo(details.tabId, dest);
387-
});

‎apps/desktop/extensions/ai-sidebar/manifest.json‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@
1515
"tabs",
1616
"activeTab",
1717
"scripting",
18-
"webNavigation",
1918
"proxy",
2019
"privacy",
2120
"notifications"

‎apps/desktop/extensions/ai-sidebar/moshpit-drift.test.js‎

Lines changed: 0 additions & 99 deletions
This file was deleted.

‎apps/desktop/extensions/ai-sidebar/moshpit-routing.js‎

Lines changed: 0 additions & 78 deletions
This file was deleted.

0 commit comments

Comments
 (0)