diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 11f0d36b..e39c4b6d 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -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
diff --git a/.github/workflows/windows-pit.yml b/.github/workflows/windows-pit.yml
new file mode 100644
index 00000000..d58eb64c
--- /dev/null
+++ b/.github/workflows/windows-pit.yml
@@ -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
diff --git a/apps/desktop/extensions/ai-sidebar/background-helper.test.js b/apps/desktop/extensions/ai-sidebar/background-helper.test.js
new file mode 100644
index 00000000..6a7afc89
--- /dev/null
+++ b/apps/desktop/extensions/ai-sidebar/background-helper.test.js
@@ -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();
+ });
+});
diff --git a/apps/desktop/extensions/ai-sidebar/sidepanel.js b/apps/desktop/extensions/ai-sidebar/sidepanel.js
index bf3589c4..2b863c7a 100644
--- a/apps/desktop/extensions/ai-sidebar/sidepanel.js
+++ b/apps/desktop/extensions/ai-sidebar/sidepanel.js
@@ -324,6 +324,8 @@ async function togglePit() {
? 'https:// 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 https:// on a pit name will warn. Run tron upgrade to get TronBrowser’s own engine, then relaunch.'
+ : trust.why === 'windows-root-setup'
+ ? 'On Windows, registry-signed HTTPS needs the optional tronbrowser.cmd --setup-pit-https 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'
? '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.';
@@ -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 tron upgrade.');
+ 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 tron upgrade 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') {
diff --git a/apps/desktop/launcher/tron-tor-helper b/apps/desktop/launcher/tron-tor-helper
index ae7ab0c0..e86682c2 100755
--- a/apps/desktop/launcher/tron-tor-helper
+++ b/apps/desktop/launcher/tron-tor-helper
@@ -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%
@@ -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"):
@@ -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()
@@ -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):
@@ -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`.
@@ -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
@@ -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.)
diff --git a/apps/desktop/launcher/tron-windows.py b/apps/desktop/launcher/tron-windows.py
new file mode 100644
index 00000000..9a31c6d3
--- /dev/null
+++ b/apps/desktop/launcher/tron-windows.py
@@ -0,0 +1,309 @@
+#!/usr/bin/env python3
+"""Windows network-helper startup and explicitly requested Pit HTTPS setup."""
+import hashlib
+import json
+import os
+from pathlib import Path
+import re
+import runpy
+import socket
+import ssl
+import subprocess
+import sys
+import tempfile
+import time
+import urllib.error
+import urllib.request
+
+HERE = Path(__file__).resolve().parent
+REGISTRY = "https://pit.moshcode.sh"
+# Rotation is a reviewed release change, never trust whatever a server offers.
+ROOT_SHA256 = "4A5766EC8C1F10F875C98965FBE8DC361A32C72BC3959516EA7B8161001E1557"
+
+
+class NoRedirect(urllib.request.HTTPRedirectHandler):
+ def redirect_request(self, req, fp, code, msg, headers, newurl):
+ raise ValueError("Redirect refused: " + req.full_url)
+
+
+class HelperUnavailable(RuntimeError):
+ pass
+
+
+def port_available(port):
+ # Some Windows stacks silently drop SYNs for closed loopback ports. Binding
+ # with exclusive ownership distinguishes that case from an occupied port.
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe:
+ if sys.platform == "win32":
+ probe.setsockopt(socket.SOL_SOCKET, socket.SO_EXCLUSIVEADDRUSE, 1)
+ try:
+ probe.bind(("127.0.0.1", port))
+ return True
+ except OSError:
+ return False
+
+
+def read_url(url, *, local=False, timeout=10, limit=65536):
+ handlers = [NoRedirect()]
+ if local:
+ handlers.append(urllib.request.ProxyHandler({}))
+ opener = urllib.request.build_opener(*handlers)
+ with opener.open(url, timeout=timeout) as response:
+ data = response.read(limit + 1)
+ if len(data) > limit:
+ raise ValueError("Response too large")
+ return data
+
+
+def helper_state(port):
+ try:
+ # Windows can take about a second to report a refused loopback socket.
+ raw = read_url("http://127.0.0.1:%d/pit/status" % port, local=True, timeout=2)
+ except urllib.error.URLError as exc:
+ # Only a refused connection means it is safe to try starting a helper.
+ if isinstance(exc.reason, ConnectionRefusedError):
+ return None
+ raise HelperUnavailable("Helper port is occupied or unresponsive") from exc
+ except (TimeoutError, OSError) as exc:
+ raise HelperUnavailable("Helper port is occupied or unresponsive") from exc
+ try:
+ state = json.loads(raw)
+ if (not isinstance(state, dict) or state.get("helper") != "tronbrowser-network"
+ or type(state.get("running")) is not bool
+ or type(state.get("port")) is not int
+ or type(state.get("pid")) is not int
+ or not isinstance(state.get("version"), str)):
+ raise ValueError("Unrecognized helper")
+ return state
+ except (ValueError, TypeError) as exc:
+ raise RuntimeError("Unrecognized or older service on the helper port; not replacing it") from exc
+
+
+def start_helper(directory=HERE, data=None, timeout=6):
+ directory = Path(directory)
+ helper = directory / "tron-tor-helper"
+ if not helper.is_file():
+ raise RuntimeError("Release is missing tron-tor-helper; reinstall the complete Windows ZIP")
+ config = runpy.run_path(str(helper))
+ port, version = config["PORT"], config["HELPER_VERSION"]
+ existing = None if port_available(port) else helper_state(port)
+ if existing is not None:
+ if existing["version"] != version:
+ raise RuntimeError("Older helper is running. Restart Windows after upgrading TronBrowser")
+ return existing
+ data = Path(data or os.environ.get("TRONBROWSER_DATA") or Path.home() / ".tronbrowser")
+ data.mkdir(parents=True, exist_ok=True)
+ env = os.environ.copy()
+ env.update(TRON_TOR_BIN_DIR=str(directory), TRON_TOR_DATA=str(data / "tor"),
+ PYTHONUTF8="1", PYTHONUNBUFFERED="1")
+ options = {"creationflags": subprocess.CREATE_NO_WINDOW | subprocess.DETACHED_PROCESS} if sys.platform == "win32" else {"start_new_session": True}
+ log_path = data / "tor-helper.log"
+ if log_path.exists() and log_path.stat().st_size > 5 * 1024 * 1024:
+ log_path.replace(data / "tor-helper.previous.log")
+ with log_path.open("ab") as log:
+ child = subprocess.Popen([sys.executable, str(helper)], stdin=subprocess.DEVNULL,
+ stdout=log, stderr=subprocess.STDOUT, env=env, **options)
+ deadline = time.monotonic() + timeout
+ try:
+ while time.monotonic() < deadline:
+ try:
+ state = helper_state(port)
+ except HelperUnavailable:
+ # Only retry transport failures while our own child starts.
+ state = None
+ if state is not None:
+ if state["version"] != version:
+ raise RuntimeError("A different helper version owns the port")
+ return state
+ if child.poll() is not None:
+ raise RuntimeError("Helper exited before becoming ready")
+ time.sleep(0.1)
+ raise RuntimeError("Helper startup timed out")
+ except Exception:
+ # Terminate only the process we just created, never a PID read from disk.
+ if child.poll() is None:
+ child.terminate()
+ child.wait(timeout=5)
+ raise
+
+
+# No interpolated commands, policy changes, machine store, or TLS exceptions.
+# Certificate validation is repeated immediately before adding to the store.
+CERTIFICATE_COMMAND = r'''
+$ErrorActionPreference = 'Stop'
+$cert = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2($env:TRON_CA_FILE)
+$sha = [System.Security.Cryptography.SHA256]::Create()
+try { $fingerprint = ([BitConverter]::ToString($sha.ComputeHash($cert.RawData))).Replace('-', '') }
+finally { $sha.Dispose() }
+if ($fingerprint -cne $env:TRON_CA_SHA256) { throw 'Certificate fingerprint mismatch' }
+$constraints = @($cert.Extensions | Where-Object { $_.Oid.Value -eq '2.5.29.19' })
+if ($constraints.Count -ne 1) { throw 'Missing or duplicate CA constraints' }
+$basic = New-Object System.Security.Cryptography.X509Certificates.X509BasicConstraintsExtension
+$basic.CopyFrom($constraints[0])
+if (-not $basic.CertificateAuthority) { throw 'Not a CA certificate' }
+$usages = @($cert.Extensions | Where-Object { $_.Oid.Value -eq '2.5.29.15' })
+if ($usages.Count -ne 1) { throw 'Missing CA key usage' }
+$usage = New-Object System.Security.Cryptography.X509Certificates.X509KeyUsageExtension
+$usage.CopyFrom($usages[0])
+if (-not ($usage.KeyUsages -band [System.Security.Cryptography.X509Certificates.X509KeyUsageFlags]::KeyCertSign)) { throw 'CA cannot sign certificates' }
+$now = [DateTime]::UtcNow
+if ($now -lt $cert.NotBefore.ToUniversalTime() -or $now -gt $cert.NotAfter.ToUniversalTime()) { throw 'Certificate is not currently valid' }
+if ($cert.Subject -ne $cert.Issuer) { throw 'Not a root certificate' }
+if ($cert.HasPrivateKey) { throw 'Unexpected private key' }
+$store = New-Object System.Security.Cryptography.X509Certificates.X509Store('Root', 'CurrentUser')
+try {
+ $flags = [System.Security.Cryptography.X509Certificates.OpenFlags]::ReadOnly
+ if ($env:TRON_CA_MODE -eq 'install') { $flags = [System.Security.Cryptography.X509Certificates.OpenFlags]::ReadWrite }
+ $store.Open($flags)
+ $existing = $store.Certificates.Find([System.Security.Cryptography.X509Certificates.X509FindType]::FindByThumbprint, $cert.Thumbprint, $false)
+ $already = $existing.Count -gt 0
+ if ($env:TRON_CA_MODE -eq 'install' -and -not $already) {
+ $store.Add($cert)
+ $confirmed = $store.Certificates.Find([System.Security.Cryptography.X509Certificates.X509FindType]::FindByThumbprint, $cert.Thumbprint, $false)
+ if ($confirmed.Count -eq 0) { throw 'Certificate was not added to CurrentUser Root' }
+ }
+ @{fingerprint=$fingerprint; thumbprint=$cert.Thumbprint; alreadyTrusted=$already; subject=$cert.Subject} | ConvertTo-Json -Compress
+} finally { $store.Close(); $cert.Dispose() }
+'''
+
+
+def certificate_action(path, fingerprint, mode):
+ if mode not in ("inspect", "install"):
+ raise ValueError("Invalid certificate action")
+ return run_certificate_command(CERTIFICATE_COMMAND, TRON_CA_FILE=str(path),
+ TRON_CA_SHA256=fingerprint, TRON_CA_MODE=mode)
+
+
+def run_certificate_command(command, **values):
+ if sys.platform != "win32":
+ raise RuntimeError("Certificate setup is Windows-only")
+ system_root = os.environ.get("SystemRoot")
+ if not system_root:
+ raise RuntimeError("Windows SystemRoot is missing; certificate setup cannot continue")
+ powershell = Path(system_root) / "System32/WindowsPowerShell/v1.0/powershell.exe"
+ env = os.environ.copy()
+ # Let Windows PowerShell construct its own module path, not inherit pwsh 7's.
+ env.pop("PSModulePath", None)
+ env.update(values)
+ result = subprocess.run([str(powershell), "-NoProfile", "-NonInteractive", "-Command", command],
+ env=env, capture_output=True, text=True, timeout=90)
+ if result.returncode != 0:
+ raise RuntimeError("Windows certificate validation/import failed (device policy may block it): " + result.stderr.strip())
+ return json.loads(result.stdout)
+
+
+# Rollback works offline and selects by the release's full SHA-256, never name.
+ROOT_REMOVAL_COMMAND = r'''
+$ErrorActionPreference = 'Stop'
+$store = New-Object System.Security.Cryptography.X509Certificates.X509Store('Root', 'CurrentUser')
+$sha = [System.Security.Cryptography.SHA256]::Create()
+try {
+ $flags = [System.Security.Cryptography.X509Certificates.OpenFlags]::ReadOnly
+ if ($env:TRON_CA_MODE -eq 'remove') { $flags = [System.Security.Cryptography.X509Certificates.OpenFlags]::ReadWrite }
+ $store.Open($flags)
+ $matches = @($store.Certificates | Where-Object {
+ ([BitConverter]::ToString($sha.ComputeHash($_.RawData))).Replace('-', '') -ceq $env:TRON_CA_SHA256
+ })
+ if ($env:TRON_CA_MODE -eq 'remove') {
+ foreach ($cert in $matches) { $store.Remove($cert) }
+ $remaining = @($store.Certificates | Where-Object {
+ ([BitConverter]::ToString($sha.ComputeHash($_.RawData))).Replace('-', '') -ceq $env:TRON_CA_SHA256
+ })
+ if ($remaining.Count -gt 0) { throw 'Root is still trusted; device policy or a machine-level root may require your administrator' }
+ }
+ @{count=$matches.Count; fingerprint=$env:TRON_CA_SHA256; thumbprints=@($matches | ForEach-Object { $_.Thumbprint })} | ConvertTo-Json -Compress
+} finally { $sha.Dispose(); $store.Close() }
+'''
+
+
+def remove_https():
+ info = run_certificate_command(ROOT_REMOVAL_COMMAND, TRON_CA_SHA256=ROOT_SHA256, TRON_CA_MODE="inspect")
+ if info["count"] == 0:
+ print("The pinned Moshpit root is not trusted in this user store; no changes made.")
+ return
+ print("This removes the Moshpit root with SHA-256 " + ROOT_SHA256)
+ print("from Current User roots, even if it was installed by another application.")
+ print("ALL apps relying on that root may stop trusting registry-signed HTTPS.")
+ print("No machine roots, DNS, or unrelated certificates will be changed.")
+ if input("Type REMOVE to continue (anything else cancels): ").strip() != "REMOVE":
+ print("Cancelled; trust was not changed.")
+ return
+ print("Windows may request confirmation. Match this thumbprint before approving:")
+ print(", ".join(info["thumbprints"]), flush=True)
+ run_certificate_command(ROOT_REMOVAL_COMMAND, TRON_CA_SHA256=ROOT_SHA256, TRON_CA_MODE="remove")
+ print("Pinned root removed. Fully restart applications to clear cached trust.")
+
+
+def download_root():
+ metadata = json.loads(read_url(REGISTRY + "/api/moshpit/ca"))
+ if not isinstance(metadata, dict) or metadata.get("enabled") is not True:
+ raise ValueError("Registry CA is not enabled")
+ root = metadata.get("root")
+ wanted = root.get("fingerprint_sha256", "") if isinstance(root, dict) else ""
+ if not isinstance(wanted, str):
+ raise ValueError("Invalid CA fingerprint")
+ wanted = wanted.replace(":", "").upper()
+ if not re.fullmatch(r"[0-9A-F]{64}", wanted):
+ raise ValueError("Invalid CA fingerprint")
+ if wanted != ROOT_SHA256:
+ raise ValueError("Registry CA changed; a reviewed TronBrowser update is required")
+ pem = read_url(REGISTRY + "/api/moshpit/ca.crt").decode("ascii").strip()
+ if not re.fullmatch(r"-----BEGIN CERTIFICATE-----\s+[A-Za-z0-9+/=\s]+-----END CERTIFICATE-----", pem):
+ raise ValueError("Expected exactly one PEM certificate")
+ der = ssl.PEM_cert_to_DER_cert(pem)
+ if hashlib.sha256(der).hexdigest().upper() != wanted:
+ raise ValueError("Registry CA fingerprint mismatch")
+ return der, wanted
+
+
+def setup_https():
+ if sys.platform != "win32":
+ raise RuntimeError("Certificate setup is Windows-only")
+ der, fingerprint = download_root()
+ with tempfile.TemporaryDirectory(prefix="tron-pit-ca-") as temp:
+ path = Path(temp) / "root.cer"
+ path.write_bytes(der)
+ info = certificate_action(path, fingerprint, "inspect")
+ print("Registry: " + REGISTRY)
+ print("CA SHA-256: " + fingerprint)
+ print("Windows thumbprint: " + info["thumbprint"])
+ if info["alreadyTrusted"]:
+ print("This exact CA is already trusted; no changes made.")
+ return
+ print("This adds a persistent root CA to your Windows CURRENT USER trusted roots.")
+ print("It can authenticate sites in ALL apps using that store, not just TronBrowser.")
+ print("This CA is NOT restricted to Moshpit names. Trust its operator only if you")
+ print("accept that authority; turning Pit off does not remove the certificate.")
+ print("No DNS or machine-wide settings will change. Do not do this on a managed")
+ print("work computer without your administrator's approval. Cancel if unsure.")
+ if input("Type TRUST to continue (anything else cancels): ").strip() != "TRUST":
+ print("Cancelled; no certificate was installed.")
+ return
+ print("Windows may show a Security Warning. Approve only if its thumbprint")
+ print("matches " + info["thumbprint"] + "; otherwise cancel.", flush=True)
+ certificate_action(path, fingerprint, "install")
+ print("Root CA installed. Restart TronBrowser, then turn Pit on.")
+ print("To undo offline: tronbrowser.cmd --remove-pit-https (requires REMOVE).")
+ print("Or use certmgr.msc and remove ONLY the certificate with this thumbprint:")
+ print(info["thumbprint"])
+
+
+def main():
+ try:
+ if sys.argv[1:] == ["setup-https"]:
+ setup_https()
+ elif sys.argv[1:] == ["remove-https"]:
+ remove_https()
+ elif sys.argv[1:] == ["start"]:
+ state = start_helper()
+ print("TronBrowser network helper ready (PID %d). Pit stays off until enabled." % state["pid"])
+ else:
+ raise ValueError("Usage: tron-windows.py start|setup-https|remove-https")
+ except (OSError, ValueError, RuntimeError, subprocess.SubprocessError, EOFError) as exc:
+ print("TronBrowser: %s" % exc, file=sys.stderr)
+ return 1
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/apps/desktop/launcher/tronbrowser b/apps/desktop/launcher/tronbrowser
index 9a395b0d..46cd024f 100755
--- a/apps/desktop/launcher/tronbrowser
+++ b/apps/desktop/launcher/tronbrowser
@@ -210,7 +210,7 @@ if [ "$TOR" != "1" ]; then
# 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.4.3
+ HELPER_VERSION=3.4.4
(
_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/launcher/tronbrowser.cmd b/apps/desktop/launcher/tronbrowser.cmd
index c1aa39a7..1b00542f 100644
--- a/apps/desktop/launcher/tronbrowser.cmd
+++ b/apps/desktop/launcher/tronbrowser.cmd
@@ -1,5 +1,5 @@
@echo off
-setlocal enabledelayedexpansion
+setlocal DisableDelayedExpansion
rem TronBrowser launcher (Windows). Runs Ungoogled Chromium ONLY (never Chrome,
rem Edge, Brave, or regular Chromium) with TronBrowser privacy flags + bundled
rem extensions (AI sidebar + MarkSyncr). `tron ` passes URLs through.
@@ -7,12 +7,45 @@ rem Mirrors apps/desktop/launcher/tronbrowser (POSIX). Override the binary with
rem set "TRONBROWSER_BROWSER=C:\path\to\ungoogled-chromium\chrome.exe".
set "DIR=%~dp0"
set "DATA=%USERPROFILE%\.tronbrowser"
+if defined TRONBROWSER_DATA set "DATA=%TRONBROWSER_DATA%"
+
+rem Validate the interpreter: Windows may have a Store alias named python.exe.
+set "PYTHON="
+set "PYTHON_ARGS="
+if exist "%DIR%python\python.exe" (
+ "%DIR%python\python.exe" -c "import sys; sys.exit(sys.version_info < (3, 9))" >nul 2>&1
+ if not errorlevel 1 set "PYTHON=%DIR%python\python.exe"
+)
+if not defined PYTHON (
+ py -3 -c "import sys; sys.exit(sys.version_info < (3, 9))" >nul 2>&1
+ if not errorlevel 1 (
+ set "PYTHON=py"
+ set "PYTHON_ARGS=-3"
+ )
+)
+if not defined PYTHON (
+ for /f "delims=" %%P in ('where python.exe 2^>nul') do (
+ if not defined PYTHON if /i not "%%P"=="%LOCALAPPDATA%\Microsoft\WindowsApps\python.exe" (
+ "%%P" -c "import sys; sys.exit(sys.version_info < (3, 9))" >nul 2>&1
+ if not errorlevel 1 set "PYTHON=%%P"
+ )
+ )
+)
+if /i "%~1"=="--setup-pit-https" goto setup_https
+if /i "%~1"=="--remove-pit-https" goto remove_https
rem Load every bundled extension (each subdir with a manifest.json).
set "EXT="
for /d %%D in ("%DIR%extensions\*") do (
if exist "%%D\manifest.json" (
- if defined EXT (set "EXT=!EXT!,%%D") else (set "EXT=%%D")
+ rem Capture paths before enabling delayed expansion to preserve punctuation.
+ set "NEXT_EXT=%%D"
+ setlocal EnableDelayedExpansion
+ if defined EXT (set "NEXT_EXT=!EXT!,!NEXT_EXT!")
+ for /f "delims=" %%E in ("!NEXT_EXT!") do (
+ endlocal
+ set "EXT=%%E"
+ )
)
)
@@ -39,6 +72,16 @@ if not defined BROWSER (
exit /b 1
)
+rem Start only the loopback helper, not Tor, Pit, or certificate installation.
+if not defined PYTHON (
+ echo TronBrowser: Pit needs Python 3.9+ from python.org, then restart TronBrowser. >&2
+) else if not exist "%DIR%tron-windows.py" (
+ echo TronBrowser: incomplete Windows package; reinstall the complete ZIP. >&2
+) else (
+ "%PYTHON%" %PYTHON_ARGS% "%DIR%tron-windows.py" start
+ if errorlevel 1 echo TronBrowser: network helper unavailable; ordinary browsing still works. >&2
+)
+
rem --enable-features=EnableTabMuting makes the tab audio indicator a clickable
rem mute/unmute control (media::kEnableTabMuting is DISABLED_BY_DEFAULT upstream;
rem stock Chrome only enables it via Finch, which an ungoogled build never gets).
@@ -47,3 +90,20 @@ rem stock Chrome only enables it via Finch, which an ungoogled build never gets)
--disable-sync --disable-features=Translate,OptimizationHints,InterestFeedContentSuggestions ^
--enable-features=EnableTabMuting ^
--log-level=2 --load-extension="%EXT%" %*
+exit /b %errorlevel%
+
+:setup_https
+if not defined PYTHON (
+ echo TronBrowser: install Python 3.9+ from python.org before setting up Pit HTTPS. >&2
+ exit /b 1
+)
+"%PYTHON%" %PYTHON_ARGS% "%DIR%tron-windows.py" setup-https
+exit /b %errorlevel%
+
+:remove_https
+if not defined PYTHON (
+ echo TronBrowser: Python 3.9+ is required; alternatively use certmgr.msc to remove the exact root. >&2
+ exit /b 1
+)
+"%PYTHON%" %PYTHON_ARGS% "%DIR%tron-windows.py" remove-https
+exit /b %errorlevel%
diff --git a/apps/desktop/scripts/build-release.sh b/apps/desktop/scripts/build-release.sh
index cefe93b5..5991d062 100755
--- a/apps/desktop/scripts/build-release.sh
+++ b/apps/desktop/scripts/build-release.sh
@@ -71,6 +71,7 @@ stage() { # dest dir
# 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"
+ install -m 0644 "$DESKTOP/launcher/tron-windows.py" "$s/tron-windows.py"
# 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
# the manual handle.
diff --git a/apps/desktop/test/browser-driver/package-lock.json b/apps/desktop/test/browser-driver/package-lock.json
new file mode 100644
index 00000000..495a5fa0
--- /dev/null
+++ b/apps/desktop/test/browser-driver/package-lock.json
@@ -0,0 +1,27 @@
+{
+ "name": "tron-windows-browser-acceptance",
+ "version": "1.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "tron-windows-browser-acceptance",
+ "version": "1.0.0",
+ "dependencies": {
+ "playwright-core": "1.63.0"
+ }
+ },
+ "node_modules/playwright-core": {
+ "version": "1.63.0",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz",
+ "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==",
+ "license": "Apache-2.0",
+ "bin": {
+ "playwright-core": "cli.js"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ }
+ }
+}
diff --git a/apps/desktop/test/browser-driver/package.json b/apps/desktop/test/browser-driver/package.json
new file mode 100644
index 00000000..f16d537a
--- /dev/null
+++ b/apps/desktop/test/browser-driver/package.json
@@ -0,0 +1,8 @@
+{
+ "name": "tron-windows-browser-acceptance",
+ "private": true,
+ "version": "1.0.0",
+ "dependencies": {
+ "playwright-core": "1.63.0"
+ }
+}
diff --git a/apps/desktop/test/fixtures/moshpit-root-ca.crt b/apps/desktop/test/fixtures/moshpit-root-ca.crt
new file mode 100644
index 00000000..87a6d74c
--- /dev/null
+++ b/apps/desktop/test/fixtures/moshpit-root-ca.crt
@@ -0,0 +1,11 @@
+-----BEGIN CERTIFICATE-----
+MIIBnDCCAUGgAwIBAgIQRGAxezez71rzl6e5/lPTRTAKBggqhkjOPQQDAjAsMRgw
+FgYDVQQDEw9Nb3NocGl0IFJvb3QgQ0ExEDAOBgNVBAoTB01vc2hwaXQwHhcNMjYw
+OTE2MTYyNjUzWhcNNDYwOTExMTYyNjUzWjAsMRgwFgYDVQQDEw9Nb3NocGl0IFJv
+b3QgQ0ExEDAOBgNVBAoTB01vc2hwaXQwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNC
+AAT04M/Vm4ypLIuYVqLhPhjp29v7CWwGzape9UE5RiDwNSApkCVJBBeRuu/xWCMI
+pAn4BbwmozfCIwZkI+9XOHBTo0UwQzASBgNVHRMBAf8ECDAGAQH/AgEBMA4GA1Ud
+DwEB/wQEAwIBBjAdBgNVHQ4EFgQU5X5CZpvtc/QpAdNcfO2MSyCJu3EwCgYIKoZI
+zj0EAwIDSQAwRgIhAOuKm14PMGs4qDiwIgSzzhNLQrs119RllEKjfFYEc6l5AiEA
+0wYe9QqkmywcXM6uGCYy5iAIMCZtXMVY1W0tOHxQssE=
+-----END CERTIFICATE-----
diff --git a/apps/desktop/test/test_windows_pit.py b/apps/desktop/test/test_windows_pit.py
new file mode 100644
index 00000000..61d4b2e0
--- /dev/null
+++ b/apps/desktop/test/test_windows_pit.py
@@ -0,0 +1,438 @@
+"""Stdlib-only tests. No public network, DNS changes, or certificate imports."""
+import contextlib
+import hashlib
+import http.client
+import importlib.util
+import io
+import json
+import os
+from pathlib import Path
+import runpy
+import shutil
+import socket
+import ssl
+import struct
+import subprocess
+import sys
+import tempfile
+import threading
+import unittest
+from unittest import mock
+import urllib.error
+
+LAUNCHER = Path(__file__).resolve().parents[1] / "launcher"
+SPEC = importlib.util.spec_from_file_location("tron_windows", LAUNCHER / "tron-windows.py")
+windows = importlib.util.module_from_spec(SPEC)
+SPEC.loader.exec_module(windows)
+
+
+def helper_module():
+ return runpy.run_path(str(LAUNCHER / "tron-tor-helper"))
+
+
+def unused_port():
+ with socket.socket() as listener:
+ listener.bind(("127.0.0.1", 0))
+ return listener.getsockname()[1]
+
+
+def state(**overrides):
+ return dict(helper="tronbrowser-network", version="3.4.4", running=False,
+ port=9081, pid=123, **overrides)
+
+
+class StartupTests(unittest.TestCase):
+ def setUp(self):
+ probe = mock.patch.object(windows, "port_available", return_value=False)
+ probe.start()
+ self.addCleanup(probe.stop)
+
+ def test_refused_port_is_only_absence_case(self):
+ with mock.patch.object(windows, "read_url", side_effect=urllib.error.URLError(ConnectionRefusedError())):
+ self.assertIsNone(windows.helper_state(1234))
+ for error in (urllib.error.URLError(TimeoutError()), TimeoutError(), OSError("busy")):
+ with self.subTest(error=error), mock.patch.object(windows, "read_url", side_effect=error):
+ with self.assertRaises(RuntimeError):
+ windows.helper_state(1234)
+
+ def test_invalid_or_old_responder_not_replaced(self):
+ for response in ({}, [], {"version": "old"}, {**state(), "pid": True}, {**state(), "version": None}):
+ with self.subTest(response=response), mock.patch.object(windows, "read_url", return_value=json.dumps(response).encode()):
+ with self.assertRaises(RuntimeError):
+ windows.helper_state(1234)
+
+ def test_reuses_matching_helper_without_spawn(self):
+ with mock.patch.object(windows, "helper_state", return_value=state()), mock.patch.object(windows.subprocess, "Popen") as spawn:
+ self.assertEqual(windows.start_helper(LAUNCHER)["pid"], 123)
+ spawn.assert_not_called()
+
+ def test_does_not_kill_stale_helper(self):
+ with mock.patch.object(windows, "helper_state", return_value={**state(), "version": "old"}), mock.patch.object(windows.subprocess, "Popen") as spawn:
+ with self.assertRaisesRegex(RuntimeError, "Older helper"):
+ windows.start_helper(LAUNCHER)
+ spawn.assert_not_called()
+
+ def test_missing_bundle_rejected(self):
+ with tempfile.TemporaryDirectory() as temp:
+ with self.assertRaisesRegex(RuntimeError, "missing tron-tor-helper"):
+ windows.start_helper(temp)
+
+ def test_failed_child_is_reaped(self):
+ with tempfile.TemporaryDirectory() as temp, mock.patch.object(windows, "helper_state", return_value=None), mock.patch.object(windows.subprocess, "Popen") as spawn:
+ spawn.return_value.poll.return_value = None
+ with self.assertRaisesRegex(RuntimeError, "timed out"):
+ windows.start_helper(LAUNCHER, data=temp, timeout=0)
+ spawn.return_value.terminate.assert_called_once()
+ spawn.return_value.wait.assert_called_once()
+ self.assertEqual(spawn.call_args.kwargs["env"]["TRON_TOR_BIN_DIR"], str(LAUNCHER))
+
+ def test_detached_windows_flags(self):
+ with tempfile.TemporaryDirectory() as temp, mock.patch.object(windows, "helper_state", side_effect=[None, state()]), mock.patch.object(windows.sys, "platform", "win32"), mock.patch.object(windows.subprocess, "CREATE_NO_WINDOW", 0x08000000, create=True), mock.patch.object(windows.subprocess, "DETACHED_PROCESS", 8, create=True), mock.patch.object(windows.subprocess, "Popen") as spawn:
+ windows.start_helper(LAUNCHER, data=temp)
+ self.assertEqual(spawn.call_args.kwargs["creationflags"], 0x08000008)
+ self.assertNotIn("shell", spawn.call_args.kwargs)
+
+ def test_own_startup_retries_transport_failures(self):
+ responses = [None, windows.HelperUnavailable("timeout"), windows.HelperUnavailable("reset"), state()]
+ with tempfile.TemporaryDirectory() as temp, mock.patch.object(windows, "helper_state", side_effect=responses), mock.patch.object(windows.subprocess, "Popen") as spawn:
+ spawn.return_value.poll.return_value = None
+ self.assertEqual(windows.start_helper(LAUNCHER, data=temp)["pid"], 123)
+ spawn.return_value.terminate.assert_not_called()
+
+ def test_occupied_unresponsive_port_never_spawns(self):
+ with mock.patch.object(windows, "helper_state", side_effect=windows.HelperUnavailable("timeout")), mock.patch.object(windows.subprocess, "Popen") as spawn:
+ with self.assertRaises(windows.HelperUnavailable):
+ windows.start_helper(LAUNCHER)
+ spawn.assert_not_called()
+
+ def test_bad_responder_during_own_startup_fails_closed(self):
+ with tempfile.TemporaryDirectory() as temp, mock.patch.object(windows, "helper_state", side_effect=[None, RuntimeError("Unrecognized helper")]), mock.patch.object(windows.subprocess, "Popen") as spawn:
+ spawn.return_value.poll.return_value = None
+ with self.assertRaisesRegex(RuntimeError, "Unrecognized helper"):
+ windows.start_helper(LAUNCHER, data=temp)
+ spawn.return_value.terminate.assert_called_once()
+
+
+class HttpTests(unittest.TestCase):
+ def setUp(self):
+ self.helper = helper_module()
+ self.server = self.helper["HelperServer"](("127.0.0.1", 0), self.helper["Handler"])
+ self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
+ self.thread.start()
+
+ def tearDown(self):
+ self.server.shutdown()
+ self.server.server_close()
+ self.thread.join(timeout=3)
+
+ def request(self, path, method="GET", headers=None):
+ with contextlib.closing(http.client.HTTPConnection("127.0.0.1", self.server.server_port, timeout=3)) as client:
+ client.request(method, path, headers=headers or {})
+ response = client.getresponse()
+ return response.status, dict(response.getheaders()), json.loads(response.read())
+
+ def test_readiness_does_not_activate_network_services(self):
+ status, headers, body = self.request("/pit/status")
+ self.assertEqual(status, 200)
+ self.assertFalse(body["running"])
+ self.assertEqual(body["helper"], "tronbrowser-network")
+ self.assertNotIn("Access-Control-Allow-Origin", headers)
+
+ def test_web_origin_cannot_control_helper(self):
+ for origin in ("https://attacker.invalid", "null", "chrome-extension://invalid"):
+ for method in ("GET", "POST"):
+ with self.subTest(origin=origin, method=method):
+ status, headers, _ = self.request("/pit/start", method, {"Origin": origin})
+ self.assertEqual(status, 403)
+ self.assertNotIn("Access-Control-Allow-Origin", headers)
+
+ def test_dns_rebinding_host_rejected(self):
+ self.assertEqual(self.request("/pit/status", headers={"Host": "attacker.invalid"})[0], 403)
+
+ def test_get_cannot_start_or_stop_services(self):
+ for path in ("/start", "/stop", "/pit/start", "/pit/stop", "/future-mutation"):
+ self.assertEqual(self.request(path)[0], 405)
+
+ def test_web_preflight_cannot_grant_access(self):
+ status, headers, _ = self.request("/pit/start", "OPTIONS", {
+ "Origin": "https://attacker.invalid", "Access-Control-Request-Method": "POST",
+ "Access-Control-Request-Private-Network": "true"})
+ self.assertEqual(status, 403)
+ self.assertNotIn("Access-Control-Allow-Origin", headers)
+ self.assertNotIn("Access-Control-Allow-Private-Network", headers)
+
+ def test_extension_origin_and_post_still_work(self):
+ origin = "chrome-extension://" + "a" * 32
+ status, headers, body = self.request("/pit/stop", "POST", {"Origin": origin})
+ self.assertEqual(status, 200)
+ self.assertTrue(body["stopped"])
+ self.assertEqual(headers["Access-Control-Allow-Origin"], origin)
+
+ def test_proxy_settings_cannot_redirect_loopback_probe(self):
+ with mock.patch.dict(os.environ, {"HTTP_PROXY": "http://127.0.0.1:1", "http_proxy": "http://127.0.0.1:1", "NO_PROXY": "", "no_proxy": ""}):
+ self.assertEqual(windows.helper_state(self.server.server_port)["helper"], "tronbrowser-network")
+
+
+class RootSetupTests(unittest.TestCase):
+ def test_removal_absent_or_cancelled_never_writes(self):
+ for count, answer in ((0, "REMOVE"), (1, "CANCEL")):
+ with self.subTest(count=count), mock.patch.object(windows, "run_certificate_command", return_value={"count": count}) as command, mock.patch("builtins.input", return_value=answer), contextlib.redirect_stdout(io.StringIO()):
+ windows.remove_https()
+ self.assertEqual(command.call_count, 1)
+ self.assertEqual(command.call_args.kwargs["TRON_CA_MODE"], "inspect")
+
+ def test_removal_requires_explicit_consent_and_pinned_fingerprint(self):
+ with mock.patch.object(windows, "run_certificate_command", return_value={"count": 1, "thumbprints": ["A" * 40]}) as command, mock.patch("builtins.input", return_value="REMOVE"), contextlib.redirect_stdout(io.StringIO()) as output, mock.patch.object(windows, "read_url") as network:
+ windows.remove_https()
+ self.assertEqual([call.kwargs["TRON_CA_MODE"] for call in command.call_args_list], ["inspect", "remove"])
+ self.assertEqual(command.call_args.kwargs["TRON_CA_SHA256"], windows.ROOT_SHA256)
+ self.assertIn("ALL apps", output.getvalue())
+ network.assert_not_called()
+
+ def test_missing_windows_environment_fails_before_powershell(self):
+ with mock.patch.object(windows.sys, "platform", "win32"), mock.patch.dict(os.environ, {}, clear=True), mock.patch.object(windows.subprocess, "run") as run:
+ with self.assertRaisesRegex(RuntimeError, "SystemRoot is missing"):
+ windows.certificate_action(Path("root.cer"), windows.ROOT_SHA256, "inspect")
+ run.assert_not_called()
+
+ def test_committed_public_ca_matches_release_pin(self):
+ pem = (LAUNCHER.parent / "test/fixtures/moshpit-root-ca.crt").read_text()
+ der = ssl.PEM_cert_to_DER_cert(pem)
+ self.assertEqual(hashlib.sha256(der).hexdigest().upper(), windows.ROOT_SHA256)
+
+ @unittest.skipUnless(sys.platform == "win32", "Needs native Windows certificate APIs")
+ def test_windows_inspects_ca_without_modifying_trust_store(self):
+ powershell = str(Path(os.environ["SystemRoot"]) / "System32/WindowsPowerShell/v1.0/powershell.exe")
+
+ def snapshot():
+ env = os.environ.copy()
+ env.pop("PSModulePath", None)
+ result = subprocess.run([powershell, "-NoProfile", "-NonInteractive", "-Command",
+ "$s = New-Object System.Security.Cryptography.X509Certificates.X509Store('Root', 'CurrentUser'); "
+ "try { $s.Open('ReadOnly'); $s.Certificates | Sort-Object Thumbprint | ForEach-Object { $_.Thumbprint } } finally { $s.Close() }"],
+ env=env, capture_output=True, text=True, timeout=15)
+ self.assertEqual(result.returncode, 0, result.stderr)
+ return result.stdout
+
+ before = snapshot()
+ with tempfile.TemporaryDirectory() as temp:
+ pem = (LAUNCHER.parent / "test/fixtures/moshpit-root-ca.crt").read_text()
+ cert = Path(temp) / "CA space !.cer"
+ cert.write_bytes(ssl.PEM_cert_to_DER_cert(pem))
+ info = windows.certificate_action(cert, windows.ROOT_SHA256, "inspect")
+ self.assertEqual(info["fingerprint"], windows.ROOT_SHA256)
+ self.assertIn("Moshpit Root CA", info["subject"])
+ with self.assertRaisesRegex(RuntimeError, "fingerprint mismatch"):
+ windows.certificate_action(cert, "0" * 64, "inspect")
+ self.assertEqual(snapshot(), before)
+
+ def test_download_requires_release_pin_and_metadata_match(self):
+ der = b"fixture DER bytes: download validates bytes, Windows validates X509"
+ pin = hashlib.sha256(der).hexdigest().upper()
+ metadata = json.dumps({"enabled": True, "root": {"fingerprint_sha256": pin}}).encode()
+ pem = ssl.DER_cert_to_PEM_cert(der).encode()
+ with mock.patch.object(windows, "ROOT_SHA256", pin), mock.patch.object(windows, "read_url", side_effect=[metadata, pem]):
+ self.assertEqual(windows.download_root(), (der, pin))
+ with mock.patch.object(windows, "read_url", return_value=metadata) as fetch:
+ with self.assertRaisesRegex(ValueError, "reviewed TronBrowser update"):
+ windows.download_root()
+ self.assertEqual(fetch.call_count, 1)
+
+ def test_download_rejects_wrong_bytes_and_multiple_certs(self):
+ pin = windows.ROOT_SHA256
+ metadata = json.dumps({"enabled": True, "root": {"fingerprint_sha256": pin}}).encode()
+ pem = ssl.DER_cert_to_PEM_cert(b"wrong").encode()
+ for cert in (pem, pem + pem):
+ with mock.patch.object(windows, "read_url", side_effect=[metadata, cert]):
+ with self.assertRaises(ValueError):
+ windows.download_root()
+
+ def test_invalid_metadata_never_downloads_certificate(self):
+ for metadata in ([], {}, {"enabled": "true"}, {"enabled": True, "root": {"fingerprint_sha256": []}}):
+ with mock.patch.object(windows, "read_url", return_value=json.dumps(metadata).encode()) as fetch:
+ with self.assertRaises(ValueError):
+ windows.download_root()
+ self.assertEqual(fetch.call_count, 1)
+
+ def test_redirects_are_refused(self):
+ request = windows.urllib.request.Request(windows.REGISTRY)
+ with self.assertRaisesRegex(ValueError, "Redirect refused"):
+ windows.NoRedirect().redirect_request(request, None, 302, "", {}, "http://untrusted.invalid")
+
+ def test_cancel_never_imports_and_temp_file_removed(self):
+ info = {"thumbprint": "A" * 40, "alreadyTrusted": False}
+ with mock.patch.object(windows.sys, "platform", "win32"), mock.patch.object(windows, "download_root", return_value=(b"der", windows.ROOT_SHA256)), mock.patch.object(windows, "certificate_action", return_value=info) as action, mock.patch("builtins.input", return_value="no"), contextlib.redirect_stdout(io.StringIO()) as output:
+ windows.setup_https()
+ self.assertEqual(action.call_count, 1)
+ self.assertEqual(action.call_args.args[2], "inspect")
+ self.assertFalse(action.call_args.args[0].exists())
+ self.assertIn("ALL apps", output.getvalue())
+ self.assertIn("NOT restricted", output.getvalue())
+
+ def test_already_trusted_never_prompts_or_imports(self):
+ info = {"thumbprint": "A" * 40, "alreadyTrusted": True}
+ with mock.patch.object(windows.sys, "platform", "win32"), mock.patch.object(windows, "download_root", return_value=(b"der", windows.ROOT_SHA256)), mock.patch.object(windows, "certificate_action", return_value=info) as action, mock.patch("builtins.input") as prompt, contextlib.redirect_stdout(io.StringIO()):
+ windows.setup_https()
+ prompt.assert_not_called()
+ self.assertEqual(action.call_count, 1)
+
+ def test_explicit_consent_imports_exact_inspected_bytes(self):
+ info = {"thumbprint": "A" * 40, "alreadyTrusted": False}
+ with mock.patch.object(windows.sys, "platform", "win32"), mock.patch.object(windows, "download_root", return_value=(b"der", windows.ROOT_SHA256)), mock.patch.object(windows, "certificate_action", return_value=info) as action, mock.patch("builtins.input", return_value="TRUST"), contextlib.redirect_stdout(io.StringIO()):
+ windows.setup_https()
+ self.assertEqual([call.args[2] for call in action.call_args_list], ["inspect", "install"])
+ self.assertEqual(action.call_args_list[0].args[:2], action.call_args_list[1].args[:2])
+
+
+class RuntimeTests(unittest.TestCase):
+ @unittest.skipUnless(shutil.which("openssl"), "OpenSSL fixture generator is required")
+ def test_socks_relays_verified_tls_and_does_not_hide_certificate_errors(self):
+ with tempfile.TemporaryDirectory() as temp:
+ cert, key = Path(temp) / "cert.pem", Path(temp) / "key.pem"
+ subprocess.run(["openssl", "req", "-x509", "-newkey", "rsa:2048", "-nodes",
+ "-keyout", str(key), "-out", str(cert), "-days", "1",
+ "-subj", "/CN=fixture.invalid", "-addext", "subjectAltName=DNS:fixture.invalid"],
+ check=True, capture_output=True, timeout=15)
+ context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
+ context.load_cert_chain(cert, key)
+ listener = socket.socket()
+ listener.bind(("127.0.0.1", 0))
+ listener.listen()
+ listener.settimeout(3)
+ port = listener.getsockname()[1]
+ helper = helper_module()
+ globals_ = helper["_pit_serve"].__globals__
+ socks = helper["PitSocks"](0)
+ socks_port = socks.sock.getsockname()[1]
+ errors = []
+
+ def serve():
+ for _ in range(2):
+ try:
+ raw, _addr = listener.accept()
+ with raw:
+ try:
+ with context.wrap_socket(raw, server_side=True) as tls:
+ tls.recv(1024)
+ tls.sendall(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok")
+ except ssl.SSLError:
+ pass # the second client deliberately does not trust our fixture
+ except Exception as exc:
+ errors.append(exc)
+
+ server = threading.Thread(target=serve, daemon=True)
+ with mock.patch.dict(globals_, {"pit_resolve": lambda name: ["127.0.0.1"]}):
+ socks.start()
+ server.start()
+ try:
+ for trusted in (True, False):
+ with socket.create_connection(("127.0.0.1", socks_port), timeout=3) as client:
+ client.sendall(b"\x05\x01\x00")
+ self.assertEqual(helper["_recv_exact"](client, 2), b"\x05\x00")
+ host = b"fixture.invalid"
+ client.sendall(b"\x05\x01\x00\x03" + bytes([len(host)]) + host + struct.pack(">H", port))
+ self.assertEqual(helper["_recv_exact"](client, 10)[1], 0)
+ tls_context = ssl.create_default_context(cafile=str(cert) if trusted else None)
+ if trusted:
+ with tls_context.wrap_socket(client, server_hostname="fixture.invalid") as tls:
+ tls.sendall(b"GET / HTTP/1.1\r\nHost: fixture.invalid\r\n\r\n")
+ self.assertIn(b"200 OK", tls.recv(4096))
+ else:
+ with self.assertRaises(ssl.SSLCertVerificationError):
+ tls_context.wrap_socket(client, server_hostname="fixture.invalid")
+ finally:
+ socks.stop()
+ listener.close()
+ server.join(timeout=5)
+ self.assertFalse(errors)
+
+ def test_real_helper_start_reuse_and_stop_without_external_network(self):
+ with tempfile.TemporaryDirectory(prefix="tron pit space ! ") as temp:
+ control, socks = unused_port(), unused_port()
+ processes = []
+ popen = subprocess.Popen
+
+ def record(*args, **kwargs):
+ child = popen(*args, **kwargs)
+ processes.append(child)
+ return child
+
+ env = {"TRON_TOR_HELPER_PORT": str(control), "TRON_PIT_SOCKS_PORT": str(socks),
+ "TRON_TOR_SOCKS_PORT": str(unused_port()), "TRON_PIT_DOH_URL": "http://127.0.0.1:1/dns-query"}
+ try:
+ with mock.patch.dict(os.environ, env), mock.patch.object(windows.subprocess, "Popen", side_effect=record):
+ first = windows.start_helper(LAUNCHER, data=temp)
+ second = windows.start_helper(LAUNCHER, data=temp)
+ self.assertEqual(first["pid"], second["pid"])
+ self.assertEqual(len(processes), 1)
+ self.assertFalse(first["running"])
+ for _ in range(2):
+ with contextlib.closing(http.client.HTTPConnection("127.0.0.1", control, timeout=4)) as client:
+ client.request("POST", "/pit/start")
+ started = json.loads(client.getresponse().read())
+ self.assertTrue(started["started"])
+ self.assertFalse(started["check"]["ok"])
+ client.request("POST", "/pit/stop")
+ self.assertTrue(json.loads(client.getresponse().read())["stopped"])
+ with self.assertRaises(OSError):
+ socket.create_connection(("127.0.0.1", socks), timeout=0.3)
+ finally:
+ for child in processes:
+ child.terminate()
+ child.wait(timeout=5)
+
+ def test_windows_exclusive_socket_binding(self):
+ helper = helper_module()
+ with mock.patch.object(sys, "platform", "win32"), mock.patch.object(socket, "SO_EXCLUSIVEADDRUSE", -5, create=True), mock.patch.object(socket, "socket") as sock:
+ helper["PitSocks"](1234)
+ sock.return_value.setsockopt.assert_called_once_with(socket.SOL_SOCKET, -5, 1)
+
+ def test_both_packagers_ship_windows_entrypoints(self):
+ root = LAUNCHER.parents[2]
+ shell = (root / "apps/desktop/scripts/build-release.sh").read_text()
+ workflow = (root / ".github/workflows/release.yml").read_text()
+ for filename in ("tron-tor-helper", "tron-windows.py", "tronbrowser.cmd"):
+ self.assertIn(filename, shell)
+ self.assertIn(filename, workflow)
+
+ @unittest.skipUnless(sys.platform == "win32", "Needs the native cmd.exe parser")
+ def test_cmd_launches_helper_and_preserves_paths_and_arguments(self):
+ with tempfile.TemporaryDirectory(prefix="tron pit space ! ") as temp:
+ directory = Path(temp)
+ for filename in ("tron-windows.py", "tron-tor-helper", "tronbrowser.cmd"):
+ shutil.copyfile(LAUNCHER / filename, directory / filename)
+ for name in ("ai-sidebar", "another extension !"):
+ extension = directory / "extensions" / name
+ extension.mkdir(parents=True)
+ (extension / "manifest.json").write_text("{}")
+ recorder = directory / "record.py"
+ recorder.write_text("import json,os,sys\nfrom pathlib import Path\nPath(os.environ['ARGV_OUT']).write_text(json.dumps(sys.argv[1:]))\n")
+ browser = directory / "browser.cmd"
+ browser.write_text('@echo off\n"%TEST_PYTHON%" "%TEST_RECORDER%" %*\nexit /b %errorlevel%\n')
+ output = directory / "args.json"
+ pidfile = directory / "helper.pid"
+ control = unused_port()
+ env = {**os.environ, "TRONBROWSER_BROWSER": str(browser), "TRONBROWSER_DATA": str(directory / "profile"),
+ "TRON_TOR_HELPER_PORT": str(control), "TRON_TOR_PIDFILE": str(pidfile), "TRON_TOR_SOCKS_PORT": str(unused_port()),
+ "TEST_PYTHON": sys.executable, "TEST_RECORDER": str(recorder), "ARGV_OUT": str(output)}
+ try:
+ # cmd.exe does not use CRT argv quoting; pass /s /c intact.
+ command = '"%s" /d /s /c ""%s" "https://example.invalid/path?a=1&b=2""' % (os.environ["COMSPEC"], directory / "tronbrowser.cmd")
+ result = subprocess.run(command, env=env, capture_output=True, text=True, timeout=25)
+ self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
+ self.assertTrue(output.exists(), result.stdout + result.stderr)
+ args = json.loads(output.read_text())
+ self.assertIn("--user-data-dir=" + str(directory / "profile"), args)
+ self.assertIn("https://example.invalid/path?a=1&b=2", args)
+ extensions = next(arg for arg in args if arg.startswith("--load-extension="))
+ self.assertIn("another extension !", extensions)
+ self.assertIn("ai-sidebar", extensions)
+ self.assertEqual(windows.helper_state(control)["pid"], int(pidfile.read_text()))
+ finally:
+ if pidfile.exists():
+ # This PID belongs to our isolated test subprocess and directory.
+ subprocess.run(["taskkill", "/PID", pidfile.read_text().strip(), "/F"], capture_output=True, timeout=5)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/apps/desktop/test/windows-pit-browser.mjs b/apps/desktop/test/windows-pit-browser.mjs
new file mode 100644
index 00000000..701ddb37
--- /dev/null
+++ b/apps/desktop/test/windows-pit-browser.mjs
@@ -0,0 +1,104 @@
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+import path from 'node:path';
+import { createRequire } from 'node:module';
+import { pitProxyConfig } from '../extensions/ai-sidebar/pit-proxy.js';
+
+const require = createRequire(path.join(process.env.PIT_PLAYWRIGHT_DIR, 'package.json'));
+const { chromium } = require('playwright-core');
+const [profile, phase, evidence, invalidTlsUrl] = process.argv.slice(2);
+const [port] = fs.readFileSync(path.join(profile, 'DevToolsActivePort'), 'utf8').split('\n');
+const browser = await chromium.connectOverCDP(`http://127.0.0.1:${port}`);
+const results = { phase, mode: process.env.PIT_BROWSER_MODE, browser: browser.version(), checks: [] };
+const check = (name) => { results.checks.push(name); console.log(`PASS: ${name}`); };
+let context;
+try {
+ context = browser.contexts()[0];
+ const worker = context.serviceWorkers()[0]
+ || await context.waitForEvent('serviceworker', { timeout: 20000 });
+ const id = new URL(worker.url()).host;
+ const controls = await context.newPage();
+ await controls.goto(`chrome-extension://${id}/options.html`);
+ const message = (type, on) => controls.evaluate(
+ ({ type, on }) => chrome.runtime.sendMessage({ type, on }), { type, on });
+
+ const deadline = Date.now() + 5000;
+ let initial, initialProxy;
+ do {
+ initial = await message('pit-status');
+ initialProxy = await controls.evaluate(() => chrome.proxy.settings.get({ incognito: false }));
+ if (!initial.enabled && initialProxy.value.mode !== 'pac_script') break;
+ await new Promise((resolve) => setTimeout(resolve, 100));
+ } while (Date.now() < deadline);
+ assert.equal(initial.enabled, false);
+ assert.notEqual(initialProxy.value.mode, 'pac_script');
+ check('fresh browser session resets both Pit state and persisted PAC');
+ const enabled = await message('pit-set', true);
+ assert.equal(enabled.enabled, true, JSON.stringify(enabled));
+ assert.equal(enabled.port, 9081);
+ assert.equal(enabled.check?.ok, true, JSON.stringify(enabled.check));
+ const proxy = await controls.evaluate(() => chrome.proxy.settings.get({ incognito: false }));
+ assert.equal(proxy.value.mode, 'pac_script');
+ assert.equal(proxy.value.pacScript.data, pitProxyConfig().pacScript.data);
+ check('real extension starts helper and installs PAC without preflight failure');
+
+ const page = await context.newPage();
+ const clearnet = await page.goto('https://example.com/', { waitUntil: 'domcontentloaded', timeout: 30000 });
+ assert.equal(clearnet.status(), 200);
+ check('normal HTTPS works while Pit is enabled with the tested fallback-only PAC');
+ const http = await page.goto('http://mosh.eggs/', { waitUntil: 'domcontentloaded', timeout: 45000 });
+ assert.equal(http.status(), 200);
+ check('HTTP Moshpit name resolves through the browser');
+
+ if (phase === 'before-trust' || phase === 'after-removal') {
+ await assert.rejects(page.goto('https://profullstack.agent/', {
+ waitUntil: 'domcontentloaded', timeout: 45000,
+ }), /ERR_CERT_AUTHORITY_INVALID/);
+ await page.screenshot({ path: path.join(evidence, `${phase}.png`) });
+ check('registry HTTPS is rejected without root trust');
+ } else {
+ const response = await page.goto('https://profullstack.agent/', {
+ waitUntil: 'domcontentloaded', timeout: 45000,
+ });
+ assert.equal(response.status(), 200);
+ assert.equal(new URL(page.url()).hostname, 'profullstack.agent');
+ assert.ok((await page.locator('body').innerText()).length > 50);
+ results.title = await page.title();
+ results.url = page.url();
+ results.security = await response.securityDetails();
+ await page.screenshot({ path: path.join(evidence, `${phase}.png`), fullPage: true });
+ check('registry HTTPS succeeds with normal browser certificate verification');
+ }
+
+ await assert.rejects(page.goto(invalidTlsUrl, {
+ waitUntil: 'domcontentloaded', timeout: 15000,
+ }), /ERR_CERT_AUTHORITY_INVALID/);
+ check('unrelated self-signed HTTPS remains rejected');
+
+ assert.equal((await message('pit-set', false)).enabled, false);
+ const off = await controls.evaluate(() => chrome.proxy.settings.get({ incognito: false }));
+ assert.notEqual(off.value.mode, 'pac_script');
+ const normal = await page.goto('https://example.com/', {
+ waitUntil: 'domcontentloaded', timeout: 30000,
+ });
+ assert.equal(normal.status(), 200);
+ check('Pit off restores normal HTTPS browsing');
+
+ // Leave persisted local state ON, then require the next fresh session to
+ // clear it. This exercises real storage.session reset, not a mocked reset.
+ assert.equal((await message('pit-set', true)).enabled, true);
+ check('Pit can be re-enabled in the same session');
+} catch (error) {
+ results.error = String(error.stack || error);
+ for (const [index, page] of (context?.pages() || []).entries()) {
+ await page.screenshot({ path: path.join(evidence, `${phase}-failure-${index}.png`) }).catch(() => {});
+ }
+ throw error;
+} finally {
+ fs.writeFileSync(path.join(evidence, `${phase}.json`), JSON.stringify(results, null, 2));
+ // A CDP attachment's close can only disconnect. Close the owned browser
+ // explicitly so the next phase is a new process with a fresh trust cache.
+ const session = await browser.newBrowserCDPSession().catch(() => null);
+ await session?.send('Browser.close').catch(() => {});
+ await browser.close();
+}
diff --git a/apps/desktop/test/windows_pit_acceptance.py b/apps/desktop/test/windows_pit_acceptance.py
new file mode 100644
index 00000000..3a06edc0
--- /dev/null
+++ b/apps/desktop/test/windows_pit_acceptance.py
@@ -0,0 +1,283 @@
+"""Opt-in, disposable GitHub Windows runner only; never a developer PC.
+
+Exercises the real cmd launcher, extension, registry root install, and browser.
+Only the exact root absent before this test may be removed during cleanup.
+"""
+import http.server
+import contextlib
+import importlib.util
+import json
+import os
+import re
+from pathlib import Path
+import shutil
+import ssl
+import subprocess
+import sys
+import tempfile
+import threading
+import time
+
+HERE = Path(__file__).resolve().parent
+LAUNCHER = HERE.parent / "launcher"
+SPEC = importlib.util.spec_from_file_location("tron_windows", LAUNCHER / "tron-windows.py")
+windows = importlib.util.module_from_spec(SPEC)
+SPEC.loader.exec_module(windows)
+
+
+def run(args, **kwargs):
+ result = subprocess.run(args, capture_output=True, text=True, timeout=120, **kwargs)
+ print(result.stdout, flush=True)
+ if result.returncode:
+ raise RuntimeError("Command failed: %s\n%s" % (args, result.stderr))
+ return result
+
+
+def remove_test_root(cert, fingerprint):
+ # Exact SHA-256 pin is checked again before the CurrentUser-only removal.
+ command = r'''
+$ErrorActionPreference = 'Stop'
+$cert = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2($env:TRON_CA_FILE)
+$sha = [System.Security.Cryptography.SHA256]::Create()
+try { $hash = ([BitConverter]::ToString($sha.ComputeHash($cert.RawData))).Replace('-', '') }
+finally { $sha.Dispose() }
+if ($hash -cne $env:TRON_CA_SHA256) { throw 'Cleanup fingerprint mismatch' }
+$store = New-Object System.Security.Cryptography.X509Certificates.X509Store('Root', 'CurrentUser')
+try {
+ $store.Open('ReadWrite')
+ $matches = $store.Certificates.Find([System.Security.Cryptography.X509Certificates.X509FindType]::FindByThumbprint, $cert.Thumbprint, $false)
+ foreach ($match in $matches) {
+ if ([Convert]::ToBase64String($match.RawData) -cne [Convert]::ToBase64String($cert.RawData)) { throw 'Cleanup certificate bytes mismatch' }
+ $store.Remove($match)
+ }
+} finally { $store.Close(); $cert.Dispose() }
+'''
+ env = os.environ.copy()
+ env.pop("PSModulePath", None)
+ env.update(TRON_CA_FILE=str(cert), TRON_CA_SHA256=fingerprint)
+ powershell = Path(os.environ["SystemRoot"]) / "System32/WindowsPowerShell/v1.0/powershell.exe"
+ run([str(powershell), "-NoProfile", "-NonInteractive", "-Command", command], env=env)
+
+
+def command_line(launcher, tail=""):
+ return 'cmd.exe /d /s /c ""%s" %s"' % (launcher, tail)
+
+
+def stop_owned_helper(pidfile, bundle):
+ if not pidfile.is_file():
+ return
+ pid = int(pidfile.read_text())
+ if pid <= 0:
+ raise RuntimeError("Invalid owned helper PID")
+ env = os.environ.copy()
+ env.pop("PSModulePath", None)
+ # Product startup resolves __file__; Windows may expand RUNNER~1 to the
+ # long user path. Compare the same canonical path, not the 8.3 spelling.
+ env.update(TRON_OWNED_PID=str(pid), TRON_HELPER_SCRIPT=str((bundle / "tron-tor-helper").resolve()))
+ powershell = Path(os.environ["SystemRoot"]) / "System32/WindowsPowerShell/v1.0/powershell.exe"
+ run([str(powershell), "-NoProfile", "-NonInteractive", "-Command", r'''
+$ErrorActionPreference = 'Stop'
+$p = Get-CimInstance Win32_Process -Filter "ProcessId=$env:TRON_OWNED_PID"
+if ($null -eq $p) { exit 0 }
+if ($p.Name -notmatch '^python([0-9.]+)?\.exe$' -or $p.CommandLine.IndexOf($env:TRON_HELPER_SCRIPT, [StringComparison]::OrdinalIgnoreCase) -lt 0) { throw 'Refusing to stop a process outside our unique test bundle' }
+& "$env:SystemRoot\System32\taskkill.exe" /PID $p.ProcessId /T /F | Out-Null
+if ($LASTEXITCODE -ne 0) { throw 'Owned helper cleanup failed' }
+'''], env=env)
+
+
+@contextlib.contextmanager
+def native_root_consent(thumbprint, evidence, label, required=True):
+ """Click the native Windows warning ONLY for the already-verified test root.
+
+ This UI automation stays inside the guarded disposable-runner harness.
+ Product code still requires both typed consent and Windows' own approval.
+ """
+ import ctypes
+ from ctypes import wintypes
+ user32 = ctypes.WinDLL("user32", use_last_error=True)
+ callback_type = ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.HWND, wintypes.LPARAM)
+ user32.EnumWindows.argtypes = [callback_type, wintypes.LPARAM]
+ user32.EnumChildWindows.argtypes = [wintypes.HWND, callback_type, wintypes.LPARAM]
+ user32.GetWindowTextLengthW.argtypes = [wintypes.HWND]
+ user32.GetWindowTextW.argtypes = [wintypes.HWND, wintypes.LPWSTR, ctypes.c_int]
+ user32.GetDlgItem.argtypes = [wintypes.HWND, ctypes.c_int]
+ user32.GetDlgItem.restype = wintypes.HWND
+ user32.SendMessageW.argtypes = [wintypes.HWND, wintypes.UINT, wintypes.WPARAM, wintypes.LPARAM]
+ user32.SendMessageW.restype = wintypes.LPARAM
+ stop = threading.Event()
+ events = []
+ clicked = set()
+ observed = {}
+
+ def text(hwnd):
+ buffer = ctypes.create_unicode_buffer(user32.GetWindowTextLengthW(hwnd) + 1)
+ user32.GetWindowTextW(hwnd, buffer, len(buffer))
+ return buffer.value
+
+ @callback_type
+ def inspect(hwnd, _):
+ if not any(title in text(hwnd).lower() for title in ("security warning", "root certificate store", "delete certificate")):
+ return True
+ labels = [text(hwnd)]
+
+ @callback_type
+ def child(child_hwnd, _):
+ labels.append(text(child_hwnd))
+ return True
+
+ user32.EnumChildWindows(hwnd, child, 0)
+ combined = " ".join(labels)
+ observed[str(hwnd)] = combined
+ normalized = re.sub(r"[^0-9A-F]", "", combined.upper())
+ if "Moshpit Root CA" in combined and thumbprint.upper() in normalized:
+ yes = user32.GetDlgItem(hwnd, 6) # IDYES, never an arbitrary dialog button.
+ if yes and hwnd not in clicked:
+ clicked.add(hwnd)
+ events.append({"thumbprint": thumbprint, "nativeWarningAccepted": True})
+ user32.SendMessageW(yes, 0x00F5, 0, 0) # BM_CLICK
+ return True
+
+ def watch():
+ while not stop.wait(0.2):
+ user32.EnumWindows(inspect, 0)
+
+ thread = threading.Thread(target=watch, daemon=True)
+ thread.start()
+ try:
+ yield
+ if required and not events:
+ raise RuntimeError("No verified native root-consent event was recorded")
+ finally:
+ stop.set()
+ thread.join(timeout=3)
+ (evidence / ("native-" + label + "-consent.json")).write_text(json.dumps({"accepted": events, "observedWarnings": observed}), encoding="utf8")
+
+
+def main():
+ if not (sys.platform == "win32" and os.environ.get("GITHUB_ACTIONS") == "true"
+ and os.environ.get("RUNNER_ENVIRONMENT") == "github-hosted"
+ and os.environ.get("TRON_PIT_DISPOSABLE_CA_TEST") == "1"):
+ raise RuntimeError("This intrusive acceptance test requires an opted-in disposable GitHub Windows runner")
+ evidence = Path(os.environ["PIT_EVIDENCE"])
+ evidence.mkdir(parents=True, exist_ok=True)
+ browser = Path(os.environ["PIT_BROWSER"])
+ if not browser.is_file():
+ raise RuntimeError("Pinned Ungoogled Chromium executable is missing")
+ for port in (9061, 9081):
+ if not windows.port_available(port):
+ raise RuntimeError("Refusing to touch an existing helper on port %d" % port)
+ with tempfile.TemporaryDirectory(prefix="tron-pit-acceptance-") as temp:
+ root = Path(temp)
+ bundle = root / "bundle space !"
+ bundle.mkdir()
+ for name in ("tronbrowser.cmd", "tron-tor-helper", "tron-windows.py"):
+ shutil.copy2(LAUNCHER / name, bundle / name)
+ shutil.copytree(HERE.parent / "extensions/ai-sidebar", bundle / "extensions/ai-sidebar")
+ for test in (bundle / "extensions").rglob("*.test.js"):
+ test.unlink()
+ profile = root / "profile space !"
+ helper_log = profile / "tor-helper.log"
+ pidfile = root / "owned-helper.pid"
+ env = os.environ.copy()
+ env.update(TRONBROWSER_BROWSER=str(browser), TRONBROWSER_DATA=str(profile),
+ TRON_TOR_PIDFILE=str(pidfile), PYTHONUTF8="1")
+ cmd = bundle / "tronbrowser.cmd"
+ der, pin = windows.download_root()
+ cert = root / "registry.cer"
+ cert.write_bytes(der)
+ initial = windows.certificate_action(cert, pin, "inspect")
+ if initial["alreadyTrusted"]:
+ raise RuntimeError("Disposable runner unexpectedly already trusts this CA; will not alter it")
+
+ # An unrelated self-signed local origin must fail before AND after setup.
+ run(["openssl", "req", "-x509", "-newkey", "rsa:2048", "-nodes", "-days", "1",
+ "-subj", "/CN=localhost", "-addext", "subjectAltName=IP:127.0.0.1",
+ "-keyout", str(root / "bad.key"), "-out", str(root / "bad.crt")])
+ class QuietHandler(http.server.BaseHTTPRequestHandler):
+ def do_GET(self):
+ self.send_response(200)
+ self.end_headers()
+ self.wfile.write(b"Untrusted fixture")
+
+ def log_message(self, *_):
+ pass
+
+ tls = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
+ tls.load_cert_chain(root / "bad.crt", root / "bad.key")
+ class BoundedTlsServer(http.server.ThreadingHTTPServer):
+ def get_request(self):
+ sock, address = super().get_request()
+ sock.settimeout(3)
+ try:
+ return tls.wrap_socket(sock, server_side=True), address
+ except Exception:
+ sock.close()
+ raise
+
+ server = BoundedTlsServer(("127.0.0.1", 0), QuietHandler)
+ thread = threading.Thread(target=server.serve_forever, daemon=True)
+ thread.start()
+ try:
+ run(command_line(cmd, "--setup-pit-https"), env=env, input="CANCEL\n")
+ assert not windows.certificate_action(cert, pin, "inspect")["alreadyTrusted"]
+ print("PASS: cancelling real setup leaves trust unchanged", flush=True)
+ for phase in ("before-trust", "after-trust", "after-trust-fresh", "after-removal"):
+ if phase == "after-trust":
+ with native_root_consent(initial["thumbprint"], evidence, "install"):
+ run(command_line(cmd, "--setup-pit-https"), env=env, input="TRUST\n")
+ assert windows.certificate_action(cert, pin, "inspect")["alreadyTrusted"]
+ print("PASS: explicit real setup imports the pinned root", flush=True)
+ already = run(command_line(cmd, "--setup-pit-https"), env=env, input="")
+ assert "already trusted; no changes made" in already.stdout
+ if phase == "after-removal":
+ run(command_line(cmd, "--remove-pit-https"), env=env, input="CANCEL\n")
+ assert windows.certificate_action(cert, pin, "inspect")["alreadyTrusted"]
+ with native_root_consent(initial["thumbprint"], evidence, "remove"):
+ run(command_line(cmd, "--remove-pit-https"), env=env, input="REMOVE\n")
+ assert not windows.certificate_action(cert, pin, "inspect")["alreadyTrusted"]
+ print("PASS: supported offline removal revokes the pinned root", flush=True)
+ if phase in ("after-trust-fresh", "after-removal"):
+ profile = root / phase
+ env["TRONBROWSER_DATA"] = str(profile)
+ active_port = profile / "DevToolsActivePort"
+ active_port.unlink(missing_ok=True)
+ env["PIT_BROWSER_MODE"] = "headed" if phase == "after-trust-fresh" else "headless"
+ flags = "" if env["PIT_BROWSER_MODE"] == "headed" else "--headless=new "
+ with (evidence / (phase + "-launcher.log")).open("w", encoding="utf8") as log:
+ child = subprocess.Popen(command_line(cmd, flags + "--remote-debugging-port=0 about:blank"),
+ env=env, stdin=subprocess.DEVNULL, stdout=log, stderr=subprocess.STDOUT)
+ try:
+ deadline = time.monotonic() + 45
+ while not active_port.is_file():
+ if child.poll() is not None or time.monotonic() > deadline:
+ raise RuntimeError("Browser did not expose DevTools; see launcher log")
+ time.sleep(0.2)
+ run(["node", str(HERE / "windows-pit-browser.mjs"), str(profile), phase,
+ str(evidence), "https://127.0.0.1:%d/" % server.server_port], env=env)
+ finally:
+ if child.poll() is None:
+ try:
+ child.wait(timeout=10)
+ except subprocess.TimeoutExpired:
+ subprocess.run(["taskkill", "/PID", str(child.pid), "/T", "/F"], capture_output=True)
+ child.wait(timeout=10)
+ print("PASS: Windows browser acceptance complete", flush=True)
+ finally:
+ # Emergency cleanup must run even if a process/PID cleanup fails.
+ try:
+ with native_root_consent(initial["thumbprint"], evidence, "cleanup", required=False):
+ remove_test_root(cert, pin)
+ assert not windows.certificate_action(cert, pin, "inspect")["alreadyTrusted"]
+ (evidence / "cleanup.json").write_text(json.dumps({"removedTestRoot": True, "sha256": pin}), encoding="utf8")
+ print("PASS: exact test root removed from disposable runner", flush=True)
+ finally:
+ server.shutdown()
+ server.server_close()
+ thread.join(timeout=3)
+ if helper_log.is_file():
+ shutil.copy2(helper_log, evidence / "tor-helper.log")
+ stop_owned_helper(pidfile, bundle)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/docs/moshpit-pit-toggle.md b/docs/moshpit-pit-toggle.md
index e95f787e..4048cade 100644
--- a/docs/moshpit-pit-toggle.md
+++ b/docs/moshpit-pit-toggle.md
@@ -1,6 +1,6 @@
# 🤘 Pit toggle — Moshpit names for one browser session
-**Status:** shipped with the AI-sidebar extension + `tron-tor-helper` 3.4.3
+**Status:** AI-sidebar extension + `tron-tor-helper` 3.4.4
**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.
@@ -174,6 +174,8 @@ would leak every lookup outside Tor, so:
| --- | --- |
| `apps/desktop/launcher/tron-tor-helper` | `/pit/*` routes, the SOCKS5 resolver, the DoH client, per-name leaf trust |
| `apps/desktop/launcher/tronbrowser` | starts the helper; `HELPER_VERSION` must match the helper's so a stale one is replaced |
+| `apps/desktop/launcher/tronbrowser.cmd` | Windows launcher, helper startup and explicit HTTPS setup/removal commands |
+| `apps/desktop/launcher/tron-windows.py` | bounded Windows readiness, pinned opt-in CA setup and offline rollback |
| `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 |
@@ -184,6 +186,13 @@ Environment knobs on the helper: `TRON_PIT_SOCKS_PORT` (9081),
## Testing the helper by hand
+The control API accepts only the literal `127.0.0.1:` Host, not
+`localhost` or an absent Host. GET is read-only (`/`, `/status`, `/pit/status`);
+start and stop use POST. The bundled extension uses simple requests without
+custom headers. Web-page origins and preflights are rejected. These checks
+reduce web-origin access; they are not authentication against other local
+processes or installed extensions.
+
```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
@@ -194,13 +203,91 @@ curl -X POST http://127.0.0.1:19061/pit/stop
## Not in this version
-- **`https://` on macOS and Windows.** Per-name trust writes the NSS database,
- which only Chromium on Linux reads. `moshcode dns enable` remains the answer
- there.
+- **Per-name HTTPS trust on macOS and Windows.** Per-name trust writes the NSS
+ 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.
- **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.
+
+## Windows launcher and HTTPS
+
+Extract the **complete Windows release ZIP** and run `tronbrowser.cmd`. Loading
+only the extension into a portable browser cannot start the helper. Install
+Python 3.9+ (3.12 or newer recommended) from python.org first. The launcher checks
+`python/python.exe` beside the launcher, then `py -3`, then Python on PATH; it
+does not download an interpreter. Set `TRONBROWSER_BROWSER` to your Ungoogled
+Chromium executable if it is not in a standard install location.
+
+The launcher starts the bundled `tron-tor-helper` on loopback and waits for a
+bounded readiness check before opening the browser. It does not enable Pit or
+Tor, change DNS, or install certificates. A failed helper cannot prevent ordinary
+browsing; details are in `%USERPROFILE%\.tronbrowser\tor-helper.log` (or the
+`TRONBROWSER_DATA` directory). A compatible existing helper is reused. An unknown
+service or stale helper is never killed by PID; restart Windows after upgrading
+if a stale helper is still running. Like the Linux helper, it can outlive the
+browser, while the extension's proxy selection resets each browser session.
+
+For **registry-signed HTTPS** there is a separate opt-in command:
+
+```bat
+tronbrowser.cmd --setup-pit-https
+```
+
+This command fetches the CA over verified HTTPS, checks both registry metadata
+and the root SHA-256 pinned in the release, validates its CA constraints, key
+usage and dates with Windows, and requires typing `TRUST` before adding it to
+**Current User / Trusted Root Certification Authorities**. It never changes
+Local Machine roots or DNS and never disables TLS verification. A root rotation
+requires a reviewed code update, not just new metadata from the registry.
+Windows can also display its native Security Warning. Match the printed
+thumbprint before approving; this OS confirmation is not suppressed.
+
+**Trust boundary:** this root is unconstrained. It can vouch for arbitrary DNS
+names in **all apps that use this Windows user store**, not only Moshpit names or
+TronBrowser. The certificate remains installed when Pit is off. Do not approve
+it on a managed/company machine without administrator authorization. Cancel the
+prompt if that trust is not acceptable; HTTP Pit routing remains usable and
+HTTPS certificate warnings remain in place. Never bypass those warnings.
+
+After setup, fully restart TronBrowser and enable Pit. HTTPS still requires a
+valid certificate for the requested hostname; an arbitrary self-signed origin
+will not become trusted. To undo, run `tronbrowser.cmd --remove-pit-https` and
+type `REMOVE`. This works offline, selects only the release-pinned SHA-256 in
+CurrentUser roots, and warns that other applications using this root are affected.
+It can remove a matching root installed by another tool, so ownership is not
+assumed. Fully restart applications afterwards to clear cached trust. If device
+policy or a machine-level root keeps it trusted, ask your administrator; this
+command does not modify LocalMachine roots. Alternatively, use `certmgr.msc`
+and remove only the certificate whose exact thumbprint setup printed. Installing
+an already trusted root is a no-op.
+
+Regression tests (no CA imports or public-network calls):
+
+```sh
+python -B -m unittest discover -s apps/desktop/test -p test_windows_pit.py -v
+```
+
+Run on Windows as well as Linux: native `.cmd` and certificate-inspection tests
+are skipped on other systems.
+
+The separate `windows-browser` CI job runs official checksum-pinned Ungoogled
+Chromium 153.0.8010.52 on a disposable Windows x64 runner, in both headed and
+headless modes. It tests the actual launcher/extension/PAC, registry HTTP and
+HTTPS, native Windows CA consent, cancellation, existing-root no-op, fresh
+profiles, proxy reset, offline CA removal, and rejection of unrelated
+self-signed certificates. After removal, a new browser process must reject the
+registry certificate again. The test saves screenshots and TLS details.
+
+This acceptance harness temporarily imports the pinned root on its disposable
+runner and removes it afterwards. It refuses to run without the explicit
+GitHub-hosted Windows guard. Do not run it on a developer or company machine.
+Its UI automation confirms only the expected native warning matching the
+verified test root; production code does not automate Windows consent.
+
+Passing this pinned-build test is not a guarantee for every browser version,
+Windows architecture, or enterprise trust policy. macOS trust remains outside
+this patch's acceptance scope.