Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion apps/desktop/extensions/ai-sidebar/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
111 changes: 104 additions & 7 deletions apps/desktop/extensions/ai-sidebar/background.js
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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' } });
Expand Down Expand Up @@ -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 */ }
})();
12 changes: 8 additions & 4 deletions apps/desktop/extensions/ai-sidebar/options.html
Original file line number Diff line number Diff line change
Expand Up @@ -79,10 +79,14 @@ <h2>Account</h2>

<h2>Name resolution</h2>
<p class="hint">
Moshpit names resolve at the operating system, not in the browser β€” run
<code>moshcode dns enable</code> 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 (<code>.eggs</code>, <code>.moshpit</code>, …).
For the whole machine, run <code>moshcode dns enable</code> 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 <strong>🀘 Pit</strong> 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. <code>https://</code> on a pit
name still needs the certificate that <code>moshcode dns enable</code> installs.
</p>

<h2>AI providers (bring your own keys)</h2>
Expand Down
52 changes: 52 additions & 0 deletions apps/desktop/extensions/ai-sidebar/pit-proxy.js
Original file line number Diff line number Diff line change
@@ -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) } };
}
55 changes: 55 additions & 0 deletions apps/desktop/extensions/ai-sidebar/pit-proxy.test.js
Original file line number Diff line number Diff line change
@@ -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);
Comment thread
ralyodio marked this conversation as resolved.
Dismissed
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();
});
});
2 changes: 2 additions & 0 deletions apps/desktop/extensions/ai-sidebar/sidepanel.css
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/extensions/ai-sidebar/sidepanel.html
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
<button id="media" class="tor-btn" title="Open Media (bittorrented.com)">πŸ“Ί</button>
<button id="tor" class="tor-btn" title="Route this session through Tor" aria-pressed="false">πŸ§… Tor</button>
<button id="tor-info" class="tor-info-btn" title="What does Tor mode do?" aria-label="About Tor mode">?</button>
<button id="pit" class="tor-btn" title="Resolve Moshpit names (.eggs, .moshpit, …) in this session β€” no root, nothing on the machine changes" aria-pressed="false">🀘 Pit</button>
<button id="settings" title="Settings">βš™</button>
</header>

Expand Down
Loading
Loading