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
2 changes: 2 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,8 @@ jobs:
New-Item -ItemType Directory -Force -Path "$stage/extensions" | Out-Null
Copy-Item apps/desktop/launcher/tronbrowser "$stage/tronbrowser"
Copy-Item apps/desktop/launcher/tronbrowser.cmd "$stage/tronbrowser.cmd"
Copy-Item apps/desktop/launcher/tron-tor-helper "$stage/tron-tor-helper"
Copy-Item apps/desktop/launcher/tron-windows.py "$stage/tron-windows.py"
Copy-Item -Recurse apps/desktop/extensions/ai-sidebar "$stage/extensions/ai-sidebar"
# Same wholesale-copy problem build-release.sh has: the vitest files
# next to the extension sources ride along into the zip. Chrome never
Expand Down
94 changes: 94 additions & 0 deletions .github/workflows/windows-pit.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
name: Network helper regression tests

on:
push:
branches: [main]
paths:
- 'apps/desktop/launcher/**'
- 'apps/desktop/test/test_windows_pit.py'
- 'apps/desktop/test/windows_pit_acceptance.py'
- 'apps/desktop/test/windows-pit-browser.mjs'
- 'apps/desktop/test/browser-driver/**'
- 'apps/desktop/extensions/ai-sidebar/**'
- 'apps/desktop/test/fixtures/**'
- 'apps/desktop/scripts/build-release.sh'
- '.github/workflows/release.yml'
- '.github/workflows/windows-pit.yml'
pull_request:
paths:
- 'apps/desktop/launcher/**'
- 'apps/desktop/test/test_windows_pit.py'
- 'apps/desktop/test/windows_pit_acceptance.py'
- 'apps/desktop/test/windows-pit-browser.mjs'
- 'apps/desktop/test/browser-driver/**'
- 'apps/desktop/extensions/ai-sidebar/**'
- 'apps/desktop/test/fixtures/**'
- 'apps/desktop/scripts/build-release.sh'
- '.github/workflows/release.yml'
- '.github/workflows/windows-pit.yml'
workflow_dispatch:

permissions:
contents: read

jobs:
helper:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest]
runs-on: ${{ matrix.os }}
timeout-minutes: 5
steps:
- uses: actions/checkout@v5
- uses: actions/setup-python@v6
with:
python-version: '3.12'
- name: Test helper and Windows launcher (no certificate imports)
run: python -B -m unittest discover -s apps/desktop/test -p test_windows_pit.py -v

windows-browser:
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
runs-on: windows-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false
- uses: actions/setup-python@v6
with:
python-version: '3.12'
- uses: actions/setup-node@v5
with:
node-version: '24'
package-manager-cache: false
- name: Fetch checksum-pinned portable Ungoogled Chromium
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$zip = Join-Path $env:RUNNER_TEMP 'ungoogled.zip'
$dest = Join-Path $env:RUNNER_TEMP 'ungoogled'
Invoke-WebRequest 'https://github.com/ungoogled-software/ungoogled-chromium-windows/releases/download/153.0.8010.52-1.1/ungoogled-chromium_153.0.8010.52-1.1_windows_x64.zip' -OutFile $zip
if ((Get-FileHash $zip -Algorithm SHA256).Hash -cne '824857DCD68BCA34FF21FFD06F55610FDEA98BE91B6328A826F3497E4881EA4A') { throw 'Browser checksum mismatch' }
Expand-Archive $zip $dest
$browser = @(Get-ChildItem $dest -Filter chrome.exe -Recurse)
if ($browser.Count -ne 1) { throw 'Unexpected browser archive layout' }
"PIT_BROWSER=$($browser[0].FullName)" >> $env:GITHUB_ENV
"PIT_PLAYWRIGHT_DIR=$env:RUNNER_TEMP/pit-playwright" >> $env:GITHUB_ENV
"PIT_EVIDENCE=$env:RUNNER_TEMP/pit-evidence" >> $env:GITHUB_ENV
- name: Install isolated browser test driver (no browser download)
shell: pwsh
run: |
New-Item -ItemType Directory -Force $env:PIT_PLAYWRIGHT_DIR | Out-Null
Copy-Item apps/desktop/test/browser-driver/package*.json $env:PIT_PLAYWRIGHT_DIR
npm ci --prefix "$env:PIT_PLAYWRIGHT_DIR" --ignore-scripts --no-audit --no-fund
- name: Real Windows browser and opt-in CA acceptance
env:
TRON_PIT_DISPOSABLE_CA_TEST: '1'
run: python -B apps/desktop/test/windows_pit_acceptance.py
- uses: actions/upload-artifact@v4
if: always()
with:
name: windows-pit-browser-evidence
path: ${{ runner.temp }}/pit-evidence
retention-days: 7
76 changes: 76 additions & 0 deletions apps/desktop/extensions/ai-sidebar/background-helper.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

describe('extension network helper requests', () => {
let listeners;
let fetchMock;

beforeEach(async () => {
vi.resetModules();
listeners = [];
const done = () => vi.fn().mockResolvedValue(undefined);
const storage = () => ({ get: vi.fn().mockResolvedValue({}), set: done(), remove: done() });
vi.stubGlobal('chrome', {
sidePanel: { setPanelBehavior: done() },
action: {
onClicked: { addListener: vi.fn() }, setBadgeText: done(),
setBadgeBackgroundColor: done(), setTitle: done(),
},
runtime: {
onInstalled: { addListener: vi.fn() },
onMessage: { addListener: (listener) => listeners.push(listener) },
sendMessage: done(),
},
storage: { local: storage(), session: storage() },
proxy: { settings: { set: done(), clear: done() } },
privacy: { network: { webRTCIPHandlingPolicy: { set: done(), clear: done() } } },
});
fetchMock = vi.fn(async (url) => ({
json: async () => url.endsWith('/pit/start')
? { started: true, port: 9081, check: { ok: true } }
: { started: true, ready: true, IsTor: true },
}));
vi.stubGlobal('fetch', fetchMock);
await import('./background.js');
});

afterEach(() => {
vi.unstubAllGlobals();
vi.resetModules();
});

function send(message) {
return new Promise((resolve, reject) => {
if (!listeners.some((listener) => listener(message, {}, resolve) === true)) {
reject(new Error('No listener handled ' + message.type));
}
});
}

function helperCalls() {
return fetchMock.mock.calls.filter(([url]) => url.startsWith('http://127.0.0.1:9061/'));
}

function expectSimpleRequests() {
for (const [, options] of helperCalls()) {
expect(options.headers).toBeUndefined();
expect(options.body).toBeUndefined();
expect(options.signal).toBeInstanceOf(AbortSignal);
}
}

it('starts and stops Pit using simple POST requests to literal loopback', async () => {
expect(await send({ type: 'pit-set', on: true })).toMatchObject({ enabled: true, port: 9081 });
expect(await send({ type: 'pit-set', on: false })).toEqual({ enabled: false });
expect(helperCalls().map(([url, options]) => [new URL(url).pathname, options.method]))
.toEqual([['/pit/start', 'POST'], ['/pit/stop', 'POST']]);
expectSimpleRequests();
});

it('uses POST for Tor mutations and GET only for status', async () => {
expect(await send({ type: 'tor-set', on: true })).toMatchObject({ enabled: true });
expect(await send({ type: 'tor-set', on: false })).toEqual({ enabled: false });
expect(helperCalls().map(([url, options]) => [new URL(url).pathname, options.method]))
.toEqual([['/start', 'POST'], ['/status', 'GET'], ['/stop', 'POST']]);
expectSimpleRequests();
});
});
4 changes: 3 additions & 1 deletion apps/desktop/extensions/ai-sidebar/sidepanel.js
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,8 @@ async function togglePit() {
? '<code>https://</code> on a pit name is trusted per name on first use, when the registry publishes its pin.'
: trust.why === 'flatpak-engine'
? 'This TronBrowser is running the Flatpak Chromium, which ignores per-name trust, so <code>https://</code> on a pit name will warn. Run <code>tron upgrade</code> to get TronBrowser’s own engine, then relaunch.'
: trust.why === 'windows-root-setup'
? 'On Windows, registry-signed HTTPS needs the optional <code>tronbrowser.cmd --setup-pit-https</code> setup. It asks before adding a persistent root CA for all apps in your Windows account. Per-name self-signed certificates are not automatically trusted.'
: trust.why === 'no-certutil'
? '<code>https://</code> on a pit name will warn until <code>certutil</code> is installed (Debian/Ubuntu: <code>libnss3-tools</code>, Fedora: <code>nss-tools</code>, Arch: <code>nss</code>).'
: '<code>https://</code> on a pit name will warn on this platform; run <code>moshcode dns enable</code> for the certificate.';
Expand All @@ -345,7 +347,7 @@ async function togglePit() {
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 <code>tron upgrade</code>.');
showNetStatus('warn', 'Couldn’t reach the TronBrowser helper. Restart through the TronBrowser launcher. On Windows, use the complete ZIP and install Python 3.9+; loading only the extension cannot start the helper. On Linux/macOS, run <code>tron upgrade</code> if needed.');
} 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 if (err === 'helper-stale') {
Expand Down
43 changes: 38 additions & 5 deletions apps/desktop/launcher/tron-tor-helper
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,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.4.3"
HELPER_VERSION = "3.4.4"
_lock = threading.Lock()
_proc = None # the running tor subprocess (or None)
_ready = False # True once tor reported Bootstrapped 100%
Expand Down Expand Up @@ -450,6 +450,8 @@ def _safe_name(name):

def trust_available():
"""Can this machine take a per-name import at all? {available, why, engine}."""
if platform.system() == "Windows":
return {"available": False, "why": "windows-root-setup", "engine": PIT_ENGINE}
if platform.system() != "Linux":
return {"available": False, "why": "unsupported-platform", "engine": PIT_ENGINE}
if not shutil.which("certutil"):
Expand Down Expand Up @@ -686,7 +688,10 @@ class PitSocks(threading.Thread):
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)
if sys.platform == "win32":
self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_EXCLUSIVEADDRUSE, 1)
else:
self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
self.sock.bind((HOST, port))
self.sock.listen(64)
self._stopping = threading.Event()
Expand Down Expand Up @@ -770,7 +775,8 @@ 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,
"trust": trust_available(), "version": HELPER_VERSION}
"trust": trust_available(), "version": HELPER_VERSION,
"helper": "tronbrowser-network", "pid": os.getpid()}


class Handler(BaseHTTPRequestHandler):
Expand All @@ -779,13 +785,28 @@ class Handler(BaseHTTPRequestHandler):
self.send_response(code)
self.send_header("Content-Type", "application/json")
# The toggle (a chrome-extension:// page) is the only intended caller.
self.send_header("Access-Control-Allow-Origin", "*")
origin = self.headers.get("Origin", "")
if re.fullmatch(r"chrome-extension://[a-p]{32}", origin):
self.send_header("Access-Control-Allow-Origin", origin)
self.send_header("Vary", "Origin")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)

def _route(self):
# The extension uses literal loopback; reject web-page requests and DNS
# rebinding hosts. CLI clients without an Origin remain supported.
if self.headers.get("Host") != "%s:%d" % (HOST, self.server.server_port):
self._send(403, {"error": "invalid-host"})
return
origin = self.headers.get("Origin")
if origin is not None and not re.fullmatch(r"chrome-extension://[a-p]{32}", origin):
self._send(403, {"error": "invalid-origin"})
return
path = self.path.split("?", 1)[0].rstrip("/") or "/"
if path not in ("/", "/status", "/pit/status") and self.command != "POST":
self._send(405, {"error": "post-required"})
return
if path == "/start":
# Non-blocking: kick Tor off, then report live state. The caller polls
# /status for `progress` and `ready`.
Expand Down Expand Up @@ -824,6 +845,10 @@ class Handler(BaseHTTPRequestHandler):
def do_POST(self):
self._route()

def do_OPTIONS(self):
# No web origin may authorize its own access to the helper.
self._send(403, {"error": "preflight-not-supported"})

def log_message(self, *args):
pass # quiet — the launcher routes our stdout to a log already

Expand Down Expand Up @@ -853,11 +878,19 @@ def _remove_pidfile():
pass


class HelperServer(ThreadingHTTPServer):
def server_bind(self):
if sys.platform == "win32":
self.allow_reuse_address = False
self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_EXCLUSIVEADDRUSE, 1)
super().server_bind()


def main():
signal.signal(signal.SIGTERM, _shutdown)
signal.signal(signal.SIGINT, _shutdown)
try:
server = ThreadingHTTPServer((HOST, PORT), Handler)
server = HelperServer((HOST, PORT), Handler)
except OSError:
# Port already bound → another helper is running. Nothing to do. (The
# launcher kills a stale helper before us, so this is rare.)
Expand Down
Loading
Loading