diff --git a/apps/desktop/extensions/ai-sidebar/background-helper.test.js b/apps/desktop/extensions/ai-sidebar/background-helper.test.js
index a1601c4..7055c8e 100644
--- a/apps/desktop/extensions/ai-sidebar/background-helper.test.js
+++ b/apps/desktop/extensions/ai-sidebar/background-helper.test.js
@@ -21,7 +21,10 @@ describe('extension network helper requests', () => {
sendMessage: done(),
},
storage: { local: storage(), session: storage(), onChanged: { addListener: vi.fn() } },
- tabs: { onUpdated: { addListener: vi.fn() }, update: done() },
+ webNavigation: { onBeforeNavigate: { addListener: vi.fn() } },
+ tabs: {
+ onUpdated: { addListener: vi.fn() }, onRemoved: { addListener: vi.fn() }, update: done(),
+ },
declarativeNetRequest: { updateSessionRules: done() },
proxy: { settings: { set: done(), clear: done() } },
privacy: { network: { webRTCIPHandlingPolicy: { set: done(), clear: done() } } },
diff --git a/apps/desktop/extensions/ai-sidebar/background.js b/apps/desktop/extensions/ai-sidebar/background.js
index e930a59..88d9388 100644
--- a/apps/desktop/extensions/ai-sidebar/background.js
+++ b/apps/desktop/extensions/ai-sidebar/background.js
@@ -333,12 +333,142 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
});
// --- 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.
+// W3BS Net is primary for recognized names; Moshpit is the backfill. For a
+// bare W3BS name (fumb.duck), route to its W3BS host or published target before
+// DNS. If W3BS has no record, the PAC and SOCKS helper use Moshpit resolution.
+// All of this is scoped to this browser session and only runs while Pit is on.
+const W3BS_NET_RESOLVE = 'https://w3bs.org/api/net/v1/resolve/';
+const W3BS_NET_NAME = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
+const W3BS_HOST_SUFFIX = '.w3bs.org';
+const pitNavigationGeneration = new Map();
+const pitNavigationPending = new Set();
+const pitNavigationChains = new Map();
+
+function w3bsNameForHost(host) {
+ const lower = String(host || '').toLowerCase().replace(/\.$/, '');
+ if (lower.endsWith(W3BS_HOST_SUFFIX)) {
+ const name = lower.slice(0, -W3BS_HOST_SUFFIX.length);
+ return W3BS_NET_NAME.test(name) ? { name } : null;
+ }
+ return W3BS_NET_NAME.test(lower) ? { name: lower } : null;
+}
+
+function appendNetPath(destination, source) {
+ const target = new URL(destination);
+ const incoming = new URL(source);
+ if (incoming.pathname !== '/') {
+ target.pathname = `${target.pathname.replace(/\/+$/, '')}/${incoming.pathname.replace(/^\/+/, '')}`;
+ }
+ if (incoming.search) target.search = incoming.search;
+ if (incoming.hash) target.hash = incoming.hash;
+ return target.href;
+}
+
+async function w3bsNetRecord(name) {
+ const controller = new AbortController();
+ const timer = setTimeout(() => controller.abort(), 2500);
+ try {
+ const response = await fetch(`${W3BS_NET_RESOLVE}${encodeURIComponent(name)}`, {
+ headers: { accept: 'application/json' },
+ redirect: 'error',
+ signal: controller.signal,
+ });
+ if (response.status === 404) return null;
+ if (!response.ok) return null;
+ return await response.json();
+ } catch (_) {
+ return null;
+ } finally {
+ clearTimeout(timer);
+ }
+}
+
+async function routeW3bsNetNavigation(details) {
+ if (details.frameId !== 0 || details.tabId < 0) return;
+ const pendingKey = `${details.tabId}:${details.url}`;
+ if (pitNavigationPending.has(pendingKey)) return;
+ const generation = (pitNavigationGeneration.get(details.tabId) || 0) + 1;
+ pitNavigationGeneration.set(details.tabId, generation);
+
+ let incoming;
+ try {
+ incoming = new URL(details.url);
+ } catch (_) {
+ return;
+ }
+ if (incoming.protocol !== 'https:') return;
+ const candidate = w3bsNameForHost(incoming.hostname);
+ if (!candidate) return;
+
+ pitNavigationPending.add(pendingKey);
+ try {
+ const { pitEnabled } = await chrome.storage.local.get('pitEnabled');
+ if (!pitEnabled || pitNavigationGeneration.get(details.tabId) !== generation) return;
+
+ const record = await w3bsNetRecord(candidate.name);
+ if (!record || pitNavigationGeneration.get(details.tabId) !== generation) return;
+
+ let base;
+ let preservePath = true;
+ if (record.source === 'w3bs') {
+ if (record.status !== 'active') {
+ base = `https://w3bs.org/net?q=${encodeURIComponent(candidate.name)}`;
+ preservePath = false;
+ } else if (typeof record.target === 'string' && record.target) {
+ try {
+ const target = new URL(record.target);
+ if (target.protocol !== 'https:' || target.username || target.password) return;
+ base = target.href;
+ } catch (_) {
+ return;
+ }
+ } else {
+ base = `https://${candidate.name}.w3bs.org/`;
+ }
+ } else if (record.source === 'moshpit') {
+ // W3BS Net imported this name as backfill; its public gateway carries the
+ // name and path without asking ordinary DNS to resolve the Moshpit TLD.
+ const resolved = typeof record.resolved === 'string' && W3BS_NET_NAME.test(record.resolved)
+ ? record.resolved
+ : candidate.name;
+ base = `https://${resolved}.pit.world/`;
+ } else {
+ return;
+ }
+
+ const destination = preservePath ? appendNetPath(base, details.url) : base;
+ if (destination === details.url || pitNavigationGeneration.get(details.tabId) !== generation) return;
+
+ const chain = pitNavigationChains.get(details.tabId) || { seen: new Set(), expiresAt: 0 };
+ if (chain.expiresAt < Date.now()) chain.seen.clear();
+ if (chain.seen.has(incoming.hostname)) return;
+ chain.seen.add(incoming.hostname);
+ chain.expiresAt = Date.now() + 15000;
+ pitNavigationChains.set(details.tabId, chain);
+
+ await chrome.tabs.update(details.tabId, { url: destination });
+ } catch (_) {
+ // A lookup or redirect failure leaves the existing Moshpit/clearnet path
+ // alone; the W3BS check must never block normal navigation.
+ } finally {
+ pitNavigationPending.delete(pendingKey);
+ }
+}
+
+chrome.webNavigation.onBeforeNavigate.addListener((details) => {
+ void routeW3bsNetNavigation(details);
+});
+chrome.tabs.onUpdated.addListener((tabId, changeInfo) => {
+ if (changeInfo.status === 'complete') {
+ pitNavigationChains.delete(tabId);
+ }
+});
+chrome.tabs.onRemoved.addListener((tabId) => {
+ pitNavigationChains.delete(tabId);
+ pitNavigationGeneration.delete(tabId);
+ for (const key of pitNavigationPending) if (key.startsWith(`${tabId}:`)) pitNavigationPending.delete(key);
+});
+
async function setPitBadge(on) {
try {
await chrome.action.setBadgeText({ text: on ? 'PIT' : '' });
diff --git a/apps/desktop/extensions/ai-sidebar/manifest.json b/apps/desktop/extensions/ai-sidebar/manifest.json
index b72b4b2..1f45347 100644
--- a/apps/desktop/extensions/ai-sidebar/manifest.json
+++ b/apps/desktop/extensions/ai-sidebar/manifest.json
@@ -17,6 +17,7 @@
"activeTab",
"scripting",
"proxy",
+ "webNavigation",
"privacy",
"notifications",
"alarms",
diff --git a/apps/desktop/extensions/ai-sidebar/pit-proxy.js b/apps/desktop/extensions/ai-sidebar/pit-proxy.js
index 41e401f..e1c791c 100644
--- a/apps/desktop/extensions/ai-sidebar/pit-proxy.js
+++ b/apps/desktop/extensions/ai-sidebar/pit-proxy.js
@@ -7,11 +7,9 @@
// 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.
+// W3BS names go through the W3BS-first resolver while Pit is on, even if public
+// DNS has a stale answer. Other names the system resolver can answer stay
+// DIRECT; unresolved names use Moshpit as the backfill. Pure functions.
/** Loopback port the helper's pit resolver listens on (tron-tor-helper PIT_SOCKS_PORT). */
export const PIT_SOCKS_PORT = 9081;
@@ -36,7 +34,12 @@ export function buildPitPac(port = PIT_SOCKS_PORT) {
" 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.
+ // W3BS Net is the primary namespace for its two-label names. Its DoH
+ // resolver falls through to public DNS when W3BS has no matching record.
+ " var suffix = '.w3bs.org';",
+ " var w3bsName = h.slice(-suffix.length) === suffix ? h.slice(0, -suffix.length) : '';",
+ ` if (w3bsName && w3bsName.split('.').length === 2) return 'SOCKS5 127.0.0.1:${p}';`,
+ // For all remaining names, ordinary DNS wins and Moshpit backfills misses.
" if (dnsResolve(h)) return 'DIRECT';",
` return 'SOCKS5 127.0.0.1:${p}';`,
'}',
diff --git a/apps/desktop/extensions/ai-sidebar/sidepanel.html b/apps/desktop/extensions/ai-sidebar/sidepanel.html
index 8b7dad4..b032511 100644
--- a/apps/desktop/extensions/ai-sidebar/sidepanel.html
+++ b/apps/desktop/extensions/ai-sidebar/sidepanel.html
@@ -15,7 +15,7 @@
-
+
diff --git a/apps/desktop/extensions/ai-sidebar/sidepanel.js b/apps/desktop/extensions/ai-sidebar/sidepanel.js
index 2b863c7..3201ae9 100644
--- a/apps/desktop/extensions/ai-sidebar/sidepanel.js
+++ b/apps/desktop/extensions/ai-sidebar/sidepanel.js
@@ -329,7 +329,7 @@ async function togglePit() {
: trust.why === 'no-certutil'
? 'https:// on a pit name will warn until certutil is installed (Debian/Ubuntu: libnss3-tools, Fedora: nss-tools, Arch: nss).'
: 'https:// on a pit name will warn on this platform; run moshcode dns enable for the certificate.';
- const tip = `Clearnet names are untouched. ${httpsTip}`;
+ const tip = `W3BS Net names take priority; Moshpit is the backfill. Other clearnet names stay direct. ${httpsTip}`;
if (!turningOn) {
setPitButton(false);
hideNetStatus();
diff --git a/docs/moshpit-pit-toggle.md b/docs/moshpit-pit-toggle.md
index 4048cad..1b3f395 100644
--- a/docs/moshpit-pit-toggle.md
+++ b/docs/moshpit-pit-toggle.md
@@ -19,7 +19,7 @@ ways to make them work, and the settings page says so:
| 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) | works on Linux: the leaf is trusted per name on first use, against the registry pin |
-| Clearnet names | forwarded to public resolvers | never touched |
+| Clearnet names | forwarded to public resolvers | ordinary sites stay direct; W3BS Net names take priority while Pit is on |
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`.
@@ -41,18 +41,22 @@ It is built exactly like the 🧅 Tor toggle:
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.
+3. **W3BS first.** While Pit is on, the extension checks two-label names against
+ W3BS Net before navigation. An active W3BS record opens its published HTTPS
+ target, or its canonical `.w3bs.org` host when no target is published.
+ A name imported from Moshpit uses its `.pit.world` gateway. A W3BS miss moves
+ on to the Moshpit resolver.
+4. **The PAC.** The extension (`apps/desktop/extensions/ai-sidebar/pit-proxy.js`)
+ installs a `pac_script` proxy config. W3BS Net hostnames go through the
+ W3BS-first resolver even when public DNS has a stale answer. For other names,
+ anything the system resolver answers goes `DIRECT`; only unresolved names go
+ to the Moshpit resolver. Loopback, single-label intranet names and IP
+ literals always go `DIRECT`.
+
+W3BS is the primary registry when Pit is enabled. Moshpit only fills names W3BS
+does not own; ordinary public sites still connect directly. This lets an active
+W3BS name override a stale public DNS answer without routing unrelated clearnet
+traffic through the pit.
The PAC is not `mandatory`: if it ever fails to evaluate, Chromium falls back to
`DIRECT` and ordinary browsing keeps working.
@@ -207,9 +211,9 @@ curl -X POST http://127.0.0.1:19061/pit/stop
database, which only Chromium on Linux reads. Windows can explicitly opt into
registry-root trust using the setup below. This does not trust self-signed
origins, and macOS trust setup remains unchanged.
-- **"Moshpit wins."** The resolvers' `MOSHPIT_RESOLVE_MODE=moshpit` lets a
- registered name override a clearnet one. The toggle only implements the
- default `fallback` policy.
+- **"Moshpit wins over clearnet."** The resolvers' `MOSHPIT_RESOLVE_MODE=moshpit`
+ lets a registered Moshpit name override a public name. TronBrowser keeps
+ Moshpit as backfill; W3BS primary records are checked first while Pit is on.
- **Persisting across launches.** Mirrors Tor deliberately; a setting to keep
the pit on would be a small follow-up.