diff --git a/auth-prompt-preload.js b/auth-prompt-preload.js
new file mode 100644
index 0000000..31ef4fd
--- /dev/null
+++ b/auth-prompt-preload.js
@@ -0,0 +1,8 @@
+// Preload for the HTTP authentication prompt (auth-prompt.html). Main pushes
+// the challenge via `auth-show`; the page answers once with `auth-answer`
+// (credentials, or null for cancel). Nothing else is exposed.
+const { contextBridge, ipcRenderer } = require("electron");
+contextBridge.exposeInMainWorld("authPrompt", {
+ onShow: (cb) => ipcRenderer.on("auth-show", (_e, req) => cb(req)),
+ answer: (id, creds) => ipcRenderer.invoke("auth-answer", id, creds),
+});
diff --git a/auth-prompt.html b/auth-prompt.html
new file mode 100644
index 0000000..e9dd8c2
--- /dev/null
+++ b/auth-prompt.html
@@ -0,0 +1,77 @@
+
+
+
+
+Sign in
+
+
+
+ Sign in
+ site
+
+ This connection is not encrypted — the password would be sent in plain text.
+
+
+
+
diff --git a/main.js b/main.js
index 75587cf..7dd6687 100644
--- a/main.js
+++ b/main.js
@@ -333,6 +333,11 @@ function versionIsNewer(candidate, current) {
return false; // equal → not newer
}
async function checkForUpdate() {
+ // Dev harness guard: a throwaway instance (THESEUS_USER_DATA) that finds a
+ // newer release on the mirror shows the same one-click "Install & restart"
+ // chip as a real install, and that installer targets the REAL install dir.
+ // 2026-09-15 a test run reinstalled the user's Theseus that way.
+ if (process.env.THESEUS_NO_UPDATE_CHECK) return;
try {
const controller = new AbortController();
const to = setTimeout(() => controller.abort(), 5000);
@@ -2506,6 +2511,70 @@ ipcMain.handle("zoom-reset", () => zoomSet(activeTab(), 100));
// the address bar. Without this, t.url is only refreshed on programmatic loads —
// navigateTab / the collision switcher — and everything else sticks on the parent.
// Internal bns:// → https:// for display, matching navigateTab's convention that
+
+// ---- HTTP authentication (401 / 407 challenges) ----
+// Without a `login` listener Electron cancels every challenge, so a site
+// behind Basic/Digest auth just rendered the server's bare 401 page
+// (silentmode.st/guardian/admin, 2026-09-15). One modal prompt at a time;
+// concurrent challenges for the same host+realm (a page plus its
+// subresources) share the first answer. Successful credentials are cached
+// by Chromium's network service for the session, so a page's later
+// requests don't re-prompt.
+const authPrompts = new Map(); // key -> Promise<{username,password}|null>
+const authPending = new Map(); // reqId -> resolve
+let authSeq = 0, authQueue = Promise.resolve();
+function promptHttpAuth(req) {
+ const run = () => new Promise((resolve) => {
+ if (!win || win.isDestroyed()) return resolve(null);
+ const id = ++authSeq;
+ const pw = new BrowserWindow({
+ parent: win, modal: true, show: false, width: 440, height: 340,
+ resizable: false, minimizable: false, maximizable: false, fullscreenable: false,
+ title: req.isProxy ? "Proxy sign-in" : "Sign in",
+ backgroundColor: nativeTheme.shouldUseDarkColors ? "#1c222c" : "#ffffff",
+ autoHideMenuBar: true,
+ webPreferences: { preload: path.join(__dirname, "auth-prompt-preload.js") },
+ });
+ let settled = false;
+ const settle = (v) => { if (settled) return; settled = true; authPending.delete(id); resolve(v); if (!pw.isDestroyed()) pw.close(); };
+ authPending.set(id, settle);
+ pw.on("closed", () => settle(null));
+ pw.webContents.on("did-finish-load", () => { pw.webContents.send("auth-show", { id, ...req }); pw.show(); });
+ pw.loadFile(path.join(__dirname, "auth-prompt.html")).catch(() => settle(null));
+ });
+ const p = authQueue.then(run, run);
+ authQueue = p.catch(() => {});
+ return p;
+}
+ipcMain.handle("auth-answer", (e, id, creds) => {
+ const settle = authPending.get(Number(id));
+ if (!settle) return;
+ // Only the prompt window itself may answer.
+ const isPrompt = [...BrowserWindow.getAllWindows()].some((w) => w.webContents === e.sender && w.getParentWindow() === win);
+ if (!isPrompt) return;
+ settle(creds && typeof creds.username === "string" ? { username: creds.username, password: String(creds.password || "") } : null);
+});
+app.on("login", (event, _wc, details, authInfo, callback) => {
+ // Proxy auth configured by an add-on is answered by its own handler.
+ if (authInfo?.isProxy && proxyLoginHandler) return;
+ event.preventDefault();
+ const host = authInfo?.host || "";
+ const port = authInfo?.port;
+ const origin = (authInfo?.isProxy ? "" : (String(details?.url || "").startsWith("http:") ? "http://" : "https://"))
+ + host + (port && port !== 80 && port !== 443 ? ":" + port : "");
+ const key = `${authInfo?.isProxy ? "proxy" : "site"}|${host}:${port}|${authInfo?.realm || ""}`;
+ let p = authPrompts.get(key);
+ if (!p) {
+ p = promptHttpAuth({ origin, realm: authInfo?.realm || "", scheme: authInfo?.scheme || "", isProxy: !!authInfo?.isProxy,
+ insecure: !authInfo?.isProxy && String(details?.url || "").startsWith("http:") });
+ authPrompts.set(key, p);
+ // Share only while the prompt is open. Once answered, a fresh challenge
+ // for the same realm means the server rejected those credentials, and
+ // the user must be asked again (Chromium caches accepted ones itself).
+ p.finally(() => authPrompts.delete(key));
+ }
+ p.then((c) => { if (c) callback(c.username, c.password); else callback(); }, () => callback());
+});
// https:// is what the user sees regardless of how the bytes were fetched.
function refreshTabUrl(tab) {
if (!tab || tab.prov?.kind === "home") return; // home is loadFile → file://; leave t.url = ""
diff --git a/package.json b/package.json
index 2198afc..8bcde9a 100644
--- a/package.json
+++ b/package.json
@@ -67,6 +67,8 @@
"sidebar-preload.js",
"approval-preload.js",
"approval.html",
+ "auth-prompt-preload.js",
+ "auth-prompt.html",
"addon-inject-preload.js",
"addon-tab-preload.js",
"addons-host.js",