@firstpick/pi-package-webui 0.4.1 → 0.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -6,7 +6,7 @@ Local browser UI for [Pi coding agent](https://www.npmjs.com/package/@earendil-w
6
6
 
7
7
  Pi Web UI gives you a local browser companion for Pi: multi-tab chat, streaming output, model controls, uploads, slash-command helpers, workspace navigation, and optional extension widgets.
8
8
 
9
- > **Security:** Pi Web UI has no authentication. It can control the spawned Pi session and run anything that session is allowed to run. It binds to `127.0.0.1` by default; only expose it on trusted networks.
9
+ > **Security:** Pi Web UI can control the spawned Pi session and run anything that session is allowed to run. It binds to `127.0.0.1` by default. Remote PIN authentication is off by default on first use; enabling it in **Controls → Network → Remote PIN auth** persists that preference for later Web UI starts.
10
10
 
11
11
  ## Requirements
12
12
 
@@ -54,6 +54,8 @@ Check a running Web UI with:
54
54
  --no-open Do not open the browser automatically
55
55
  --no-session Start Pi RPC with --no-session
56
56
  --name <name> Initial Web UI tab name
57
+ --remote-auth Enable startup PIN authentication for non-local clients
58
+ --no-remote-auth Disable startup PIN authentication
57
59
  -- <pi args...> Extra arguments forwarded to Pi RPC
58
60
  ```
59
61
 
@@ -63,6 +65,7 @@ Examples:
63
65
  /webui-start
64
66
  /webui-start 31500
65
67
  /webui-start --port 31500 --no-open
68
+ /webui-start --remote-auth --host 0.0.0.0
66
69
  /webui-start --name browser -- --model anthropic/claude-sonnet-4-5:high
67
70
  ```
68
71
 
@@ -74,7 +77,7 @@ Running `/webui-start` again on the same URL restarts the server and restores cu
74
77
  /webui-status [detailed] [port] [--port N] [--host HOST]
75
78
  ```
76
79
 
77
- `/webui-status` reports the URL, online state, and network exposure. `detailed` adds tabs, sessions, models/providers, and recent backend events.
80
+ `/webui-status` reports the URL, online state, network exposure, and Remote PIN auth state. `detailed` adds tabs, sessions, models/providers, and recent backend events.
78
81
 
79
82
  ## Standalone CLI
80
83
 
@@ -96,6 +99,8 @@ pi-webui [options] [-- <pi args...>]
96
99
  --pi <command> Pi executable to spawn (default: bundled dependency, then "pi")
97
100
  --no-session Start Pi RPC with --no-session
98
101
  --name <name> Initial Web UI tab name
102
+ --remote-auth Enable startup PIN authentication for non-local clients
103
+ --no-remote-auth Disable startup PIN authentication
99
104
  -h, --help Show help
100
105
  -v, --version Print version
101
106
  ```
@@ -107,6 +112,7 @@ Examples:
107
112
  ```bash
108
113
  pi-webui
109
114
  pi-webui --cwd ~/src/my-project
115
+ pi-webui --host 0.0.0.0 --remote-auth --cwd ~/src/my-project
110
116
  pi-webui --port 3000 -- --model anthropic/claude-sonnet-4-5:high
111
117
  PI_WEBUI_PI_BIN=/path/to/pi pi-webui --no-session
112
118
  ```
@@ -116,6 +122,8 @@ Environment variables:
116
122
  - `PI_WEBUI_HOST`
117
123
  - `PI_WEBUI_PORT`
118
124
  - `PI_WEBUI_PI_BIN`
125
+ - `PI_WEBUI_REMOTE_AUTH=1` to start with remote PIN authentication enabled
126
+ - `PI_WEBUI_SETTINGS_FILE=/path/to/settings.json` to override where Web UI stores persisted settings such as the Remote PIN auth preference
119
127
 
120
128
  ## Main features
121
129
 
@@ -123,7 +131,7 @@ Environment variables:
123
131
  - Multi-tab Pi sessions with isolated processes, working directories, prompt drafts, activity state, and a workspace dashboard for common actions.
124
132
  - Unified command palette (`Ctrl/Cmd+K`) for commands, tabs, models, sessions, settings, and frequent Web UI actions.
125
133
  - Automatic tab naming from the first prompt, with `--name <name>` still available for an explicit initial tab name.
126
- - Streaming chat transcript with Markdown, thinking output, tool/bash cards, queue and compaction events, edit-and-retry from user prompts, and abort controls.
134
+ - Streaming chat transcript with Markdown, thinking output, tool/bash cards, queue and compaction events, edit-and-retry from user prompts, and guarded abort controls that require holding Esc or the Abort button for 3 seconds.
127
135
  - Prompt composer with uploads, drag/drop/paste, inline image support, slash-command autocomplete, and `@` file/path references with live suggestions.
128
136
  - Browser dialogs for common Pi selectors such as `/model`, `/settings`, `/theme`, `/fork`, `/clone`, `/resume`, `/tree`, `/scoped-models`, `/tools`, and `/skills`.
129
137
  - Model, thinking, session, workspace, theme, optional-feature, Codex usage, network, update/restart, event, and notification controls in the side panel.
@@ -143,6 +151,7 @@ Useful browser endpoints exposed by the local server include:
143
151
  - `GET /api/optional-features` for optional companion package install/update status.
144
152
  - `POST /api/optional-feature-install` for installing or updating known optional companion packages from the side panel.
145
153
  - `GET /api/update-status` and localhost-only `POST /api/update` for checking Pi/Web UI updates and running `pi update` plus all detected local/global Web UI and Pi package-manager updates followed by a Web UI server restart.
154
+ - `GET /api/remote-auth`, `POST /api/remote-auth`, and localhost-only `POST /api/remote-auth/settings` for optional 4-digit PIN authentication when serving non-local browser clients.
146
155
 
147
156
  For local development, run the checkout helper directly, for example:
148
157
 
@@ -167,6 +176,7 @@ Optional companions:
167
176
  - `@firstpick/pi-extension-setup-skills` — TUI `/skills` setup command alongside WebUI-native skill toggles.
168
177
  - `@firstpick/pi-extension-todo-progress` — todo-progress rendering.
169
178
  - `@firstpick/pi-extension-tools` — TUI `/tools` active-tool manager alongside WebUI-native tool toggles.
179
+ - `@firstpick/pi-package-remote-webui` — `/remote` trusted-LAN QR helper for connecting mobile browsers to Web UI.
170
180
  - `@firstpick/pi-extension-git-footer-status` — richer extension-owned git/footer status, including the structured Web UI footer payload.
171
181
  - `@firstpick/pi-extension-stats` — stats commands and status data.
172
182
  - `@firstpick/pi-themes-bundle` — Web UI and Pi theme resources.
@@ -197,8 +207,11 @@ This requires `/git-staged-msg` and `/pr` from `@firstpick/pi-prompts-git-pr`; b
197
207
 
198
208
  - Default bind is localhost-only: `127.0.0.1:31415`.
199
209
  - The side-panel **Open to network** button rebinds the server to `0.0.0.0`, shows LAN URLs when available, and toggles to "Close for network".
200
- - `--host 0.0.0.0` also exposes the Web UI to the local network.
201
- - Any connected browser client can control Pi and run Web UI bash actions as the Web UI process user.
210
+ - The side-panel **Remote PIN auth** toggle is off by default on first use. When enabled, the server saves that preference, generates a fresh random 4-digit PIN for each server start, shows it in Controls and `/webui-status`, and requires it from non-local browser clients.
211
+ - Localhost clients stay frictionless and can toggle Remote PIN auth; changing the toggle persists the preference and disconnects existing event streams so remote clients must re-authenticate after enablement.
212
+ - `--host 0.0.0.0` also exposes the Web UI to the local network; pass `--remote-auth` to start with PIN auth already enabled.
213
+ - Any connected browser client with access (and the PIN, if enabled) can control Pi and run Web UI bash actions as the Web UI process user.
214
+ - Remote PIN auth is a simple trusted-LAN HTTP gate, not hardened multi-user authentication; do not expose it to untrusted networks.
202
215
  - The Web UI update endpoint is restricted to localhost, because it runs package update commands and restarts the server.
203
216
  - Treat Pi Web UI as a local companion, not a hardened multi-user web service.
204
217
 
@@ -207,4 +220,5 @@ This requires `/git-staged-msg` and `/pr` from `@firstpick/pi-prompts-git-pr`; b
207
220
  - **`/webui-start` is missing:** restart Pi after installing the package.
208
221
  - **Wrong port or existing server:** use `/webui-status detailed`, or start on another port with `/webui-start --port 31500`.
209
222
  - **Optional feature is disabled or missing:** check the side panel, install the companion package if needed, then run `/reload` in the active Pi tab.
223
+ - **Remote browser asks for a PIN:** read it from **Controls → Network → Remote PIN auth**, `/webui-status`, or the local Web UI server log. Disable the toggle from localhost to remove the PIN gate.
210
224
  - **PWA install or notifications are unavailable:** use `localhost` or HTTPS; browser support varies on LAN HTTP URLs.
@@ -445,8 +445,8 @@
445
445
  "priority": "P1",
446
446
  "sensitive": false,
447
447
  "guards": ["confirmation"],
448
- "currentBehavior": "Escape aborts active user bash first, then active agent runs, and closes UI surfaces with tab scoping.",
449
- "targetBehavior": "Abort active bash first where applicable; keep agent/tab scoping and clear UI cancellation rules."
448
+ "currentBehavior": "Holding Escape for 3 seconds aborts active user bash first, then active agent runs, and closes UI surfaces with tab scoping before arming abort.",
449
+ "targetBehavior": "Abort active bash first where applicable; keep agent/tab scoping and clear UI cancellation rules with a guarded hold-to-abort affordance."
450
450
  },
451
451
  {
452
452
  "id": "shortcut.editor.clear",
package/bin/pi-webui.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { spawn } from "node:child_process";
3
- import { createHash, randomUUID } from "node:crypto";
3
+ import { createHash, randomInt, randomUUID, timingSafeEqual } from "node:crypto";
4
4
  import { createReadStream } from "node:fs";
5
5
  import { createServer } from "node:http";
6
6
  import { createRequire } from "node:module";
@@ -43,6 +43,7 @@ const publicDir = path.join(packageRoot, "public");
43
43
  const webuiHelperExtensionPath = path.join(packageRoot, "webui-rpc-helper.mjs");
44
44
  const agentDir = process.env.PI_CODING_AGENT_DIR || path.join(homedir(), ".pi", "agent");
45
45
  const OPTIONAL_FEATURE_INSTALL_ROOT_ENV = "PI_WEBUI_OPTIONAL_FEATURE_INSTALL_ROOT";
46
+ const WEBUI_SETTINGS_FILE_ENV = "PI_WEBUI_SETTINGS_FILE";
46
47
  const packageJson = JSON.parse(await readFile(path.join(packageRoot, "package.json"), "utf8"));
47
48
  let piPackageJson = {};
48
49
  try {
@@ -227,6 +228,7 @@ const OPTIONAL_FEATURE_PACKAGES = new Map([
227
228
  ["tuiSkillsCommand", "@firstpick/pi-extension-setup-skills"],
228
229
  ["todoProgressWidget", "@firstpick/pi-extension-todo-progress"],
229
230
  ["tuiToolsCommand", "@firstpick/pi-extension-tools"],
231
+ ["remoteWebui", "@firstpick/pi-package-remote-webui"],
230
232
  ["gitFooterStatus", "@firstpick/pi-extension-git-footer-status"],
231
233
  ["statsCommand", "@firstpick/pi-extension-stats"],
232
234
  ["themeBundle", "@firstpick/pi-themes-bundle"],
@@ -249,6 +251,8 @@ Options:
249
251
  --pi <command> Pi executable to spawn (default: bundled dependency, then "pi")
250
252
  --no-session Start Pi RPC with --no-session
251
253
  --name <name> Initial Web UI tab display name
254
+ --remote-auth Enable startup PIN authentication for non-local clients
255
+ --no-remote-auth Disable startup PIN authentication
252
256
  -h, --help Show this help
253
257
  -v, --version Print version
254
258
 
@@ -262,8 +266,9 @@ Examples:
262
266
  PI_WEBUI_PI_BIN=/path/to/pi pi-webui --no-session
263
267
 
264
268
  Security:
265
- The web UI has no authentication and can control Pi tools. It binds to
266
- localhost by default. Do not expose it on untrusted networks.
269
+ The web UI controls Pi tools. It binds to localhost by default. Remote PIN
270
+ authentication is off by default on first use; enabling it in Controls saves
271
+ that preference for later starts.
267
272
  `);
268
273
  }
269
274
 
@@ -285,6 +290,8 @@ function parseArgs(argv) {
285
290
  piBinExplicit: !!process.env.PI_WEBUI_PI_BIN,
286
291
  noSession: false,
287
292
  name: undefined,
293
+ remoteAuth: isTruthyEnv(process.env.PI_WEBUI_REMOTE_AUTH),
294
+ remoteAuthExplicit: process.env.PI_WEBUI_REMOTE_AUTH !== undefined,
288
295
  piArgs: [],
289
296
  help: false,
290
297
  version: false,
@@ -339,6 +346,16 @@ function parseArgs(argv) {
339
346
  i++;
340
347
  continue;
341
348
  }
349
+ if (arg === "--remote-auth") {
350
+ options.remoteAuth = true;
351
+ options.remoteAuthExplicit = true;
352
+ continue;
353
+ }
354
+ if (arg === "--no-remote-auth") {
355
+ options.remoteAuth = false;
356
+ options.remoteAuthExplicit = true;
357
+ continue;
358
+ }
342
359
  throw new Error(`Unknown option: ${arg}. Pass Pi CLI args after --.`);
343
360
  }
344
361
 
@@ -649,6 +666,153 @@ async function readJsonBody(req, { limitBytes = BODY_LIMIT_BYTES } = {}) {
649
666
  return JSON.parse(text);
650
667
  }
651
668
 
669
+ function parseCookieHeader(header = "") {
670
+ const cookies = new Map();
671
+ for (const part of String(header || "").split(";")) {
672
+ const index = part.indexOf("=");
673
+ if (index === -1) continue;
674
+ const name = part.slice(0, index).trim();
675
+ const value = part.slice(index + 1).trim();
676
+ if (name) {
677
+ try {
678
+ cookies.set(name, decodeURIComponent(value));
679
+ } catch {
680
+ cookies.set(name, value);
681
+ }
682
+ }
683
+ }
684
+ return cookies;
685
+ }
686
+
687
+ function safeTimingEqual(a = "", b = "") {
688
+ const left = Buffer.from(String(a));
689
+ const right = Buffer.from(String(b));
690
+ return left.length === right.length && timingSafeEqual(left, right);
691
+ }
692
+
693
+ function safeReturnPath(value) {
694
+ const text = String(value || "/").trim();
695
+ if (!text.startsWith("/") || text.startsWith("//")) return "/";
696
+ return text;
697
+ }
698
+
699
+ function remoteAuthCookie(token = remoteAuth.token) {
700
+ const maxAge = Math.max(0, Math.floor((remoteAuth.tokenExpiresAt - Date.now()) / 1000));
701
+ return `pi_remote_auth=${encodeURIComponent(token || "")}; HttpOnly; SameSite=Lax; Path=/; Max-Age=${maxAge}`;
702
+ }
703
+
704
+ function clearRemoteAuthCookie() {
705
+ return "pi_remote_auth=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0";
706
+ }
707
+
708
+ function requestHasRemoteAuth(req) {
709
+ if (!remoteAuthRequired()) return true;
710
+ const token = parseCookieHeader(req.headers.cookie).get("pi_remote_auth");
711
+ return !!(token && remoteAuth.token && remoteAuth.tokenExpiresAt > Date.now() && safeTimingEqual(token, remoteAuth.token));
712
+ }
713
+
714
+ function isRemoteAuthPublicPath(pathname) {
715
+ return pathname === "/remote-auth" || pathname === "/api/remote-auth" || pathname === "/favicon.svg";
716
+ }
717
+
718
+ function shouldChallengeRemoteAuth(req, url) {
719
+ if (isLocalRequest(req) || !remoteAuthRequired() || isRemoteAuthPublicPath(url.pathname)) return false;
720
+ return !requestHasRemoteAuth(req);
721
+ }
722
+
723
+ function sendRemoteAuthPage(res, returnPath = "/") {
724
+ const safeReturn = safeReturnPath(returnPath);
725
+ const body = `<!doctype html>
726
+ <html lang="en">
727
+ <head>
728
+ <meta charset="utf-8">
729
+ <meta name="viewport" content="width=device-width, initial-scale=1">
730
+ <title>Pi Web UI Remote PIN</title>
731
+ <style>
732
+ :root { color-scheme: dark; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #0f172a; color: #e5e7eb; }
733
+ body { min-height: 100vh; display: grid; place-items: center; margin: 0; padding: 24px; box-sizing: border-box; }
734
+ main { width: min(420px, 100%); padding: 28px; border: 1px solid rgba(148, 163, 184, 0.28); border-radius: 20px; background: rgba(15, 23, 42, 0.92); box-shadow: 0 24px 80px rgba(0, 0, 0, 0.35); }
735
+ h1 { margin: 0 0 8px; font-size: 1.45rem; }
736
+ p { margin: 0 0 20px; color: #94a3b8; line-height: 1.5; }
737
+ label { display: block; margin-bottom: 8px; color: #cbd5e1; font-weight: 650; }
738
+ input { width: 100%; box-sizing: border-box; border: 1px solid rgba(148, 163, 184, 0.36); border-radius: 14px; padding: 14px 16px; background: #020617; color: #f8fafc; font: inherit; font-size: 1.6rem; letter-spacing: 0.32em; text-align: center; }
739
+ button { width: 100%; margin-top: 16px; border: 0; border-radius: 14px; padding: 14px 16px; background: #22c55e; color: #052e16; font: inherit; font-weight: 800; cursor: pointer; }
740
+ button:disabled { opacity: 0.65; cursor: wait; }
741
+ .error { min-height: 1.4em; margin-top: 14px; color: #fca5a5; }
742
+ </style>
743
+ </head>
744
+ <body>
745
+ <main>
746
+ <h1>Remote PIN required</h1>
747
+ <p>Scan a trusted /remote QR code to unlock automatically, or enter the 4-digit PIN shown in the local Pi terminal or local Web UI.</p>
748
+ <form id="pinForm" autocomplete="off">
749
+ <label for="pin">PIN</label>
750
+ <input id="pin" name="pin" inputmode="numeric" pattern="[0-9]{4}" maxlength="4" autofocus required>
751
+ <button id="submit" type="submit">Unlock Web UI</button>
752
+ <div id="error" class="error" role="alert"></div>
753
+ </form>
754
+ </main>
755
+ <script>
756
+ const returnPath = ${JSON.stringify(safeReturn).replace(/</g, "\\u003c")};
757
+ const form = document.getElementById("pinForm");
758
+ const input = document.getElementById("pin");
759
+ const button = document.getElementById("submit");
760
+ const error = document.getElementById("error");
761
+ function pinFromHash() {
762
+ const params = new URLSearchParams(String(window.location.hash || "").replace(/^#/, ""));
763
+ const pin = String(params.get("pin") || "").trim();
764
+ return /^\\d{4}$/.test(pin) ? pin : "";
765
+ }
766
+ async function submitPin(pin) {
767
+ button.disabled = true;
768
+ error.textContent = "";
769
+ try {
770
+ const response = await fetch("/api/remote-auth", {
771
+ method: "POST",
772
+ headers: { "content-type": "application/json" },
773
+ body: JSON.stringify({ pin }),
774
+ });
775
+ const data = await response.json().catch(() => ({}));
776
+ if (!response.ok || data.ok !== true) throw new Error(data.error || "Incorrect PIN");
777
+ window.location.replace(returnPath || "/");
778
+ } catch (err) {
779
+ error.textContent = err?.message || String(err);
780
+ input.select();
781
+ } finally {
782
+ button.disabled = false;
783
+ }
784
+ }
785
+ input.addEventListener("input", () => { input.value = input.value.replace(/\\D/g, "").slice(0, 4); error.textContent = ""; });
786
+ form.addEventListener("submit", async (event) => {
787
+ event.preventDefault();
788
+ await submitPin(input.value);
789
+ });
790
+ const autoPin = pinFromHash();
791
+ if (autoPin) {
792
+ input.value = autoPin;
793
+ window.history.replaceState(null, "", window.location.pathname + (window.location.search || ""));
794
+ submitPin(autoPin);
795
+ }
796
+ </script>
797
+ </body>
798
+ </html>`;
799
+ res.writeHead(200, {
800
+ "content-type": "text/html; charset=utf-8",
801
+ "cache-control": "no-store",
802
+ "x-content-type-options": "nosniff",
803
+ });
804
+ res.end(body);
805
+ }
806
+
807
+ function sendRemoteAuthRequired(req, res, url) {
808
+ const acceptsHtml = String(req.headers.accept || "").includes("text/html");
809
+ if (req.method === "GET" && (acceptsHtml || url.pathname === "/" || url.pathname === "/index.html" || url.pathname === "/remote-auth")) {
810
+ sendRemoteAuthPage(res, `${url.pathname}${url.search || ""}`);
811
+ return;
812
+ }
813
+ sendJson(res, 401, { ok: false, error: "Remote PIN required", remoteAuthRequired: true }, { "www-authenticate": "PiRemotePin" });
814
+ }
815
+
652
816
  function sendSse(res, event) {
653
817
  res.write(`data: ${JSON.stringify(event)}\n\n`);
654
818
  }
@@ -1187,6 +1351,50 @@ async function writePathFastPicks(picks) {
1187
1351
  return normalized;
1188
1352
  }
1189
1353
 
1354
+ function webuiSettingsFile() {
1355
+ if (process.env[WEBUI_SETTINGS_FILE_ENV]) return path.resolve(expandUserPath(process.env[WEBUI_SETTINGS_FILE_ENV]));
1356
+ const configRoot = process.env.XDG_CONFIG_HOME || path.join(homedir(), ".config");
1357
+ return path.join(configRoot, "pi-webui", "settings.json");
1358
+ }
1359
+
1360
+ function normalizeWebuiSettings(value) {
1361
+ return {
1362
+ version: 1,
1363
+ remoteAuthEnabled: value?.remoteAuthEnabled === true,
1364
+ };
1365
+ }
1366
+
1367
+ let webuiSettingsCache = null;
1368
+
1369
+ async function readWebuiSettings() {
1370
+ if (webuiSettingsCache) return webuiSettingsCache;
1371
+ webuiSettingsCache = normalizeWebuiSettings(await readJsonFileIfExists(webuiSettingsFile()));
1372
+ return webuiSettingsCache;
1373
+ }
1374
+
1375
+ async function writeWebuiSettings(patch) {
1376
+ const current = await readWebuiSettings();
1377
+ const next = normalizeWebuiSettings({ ...current, ...(patch || {}) });
1378
+ const storageFile = webuiSettingsFile();
1379
+ await mkdir(path.dirname(storageFile), { recursive: true });
1380
+ const tmpFile = `${storageFile}.${process.pid}.${Date.now()}.tmp`;
1381
+ await writeFile(tmpFile, `${JSON.stringify(next, null, 2)}\n`, { mode: 0o600 });
1382
+ await rename(tmpFile, storageFile);
1383
+ webuiSettingsCache = next;
1384
+ return next;
1385
+ }
1386
+
1387
+ async function readPersistedRemoteAuthEnabled() {
1388
+ return (await readWebuiSettings()).remoteAuthEnabled === true;
1389
+ }
1390
+
1391
+ async function saveRemoteAuthPreference(enabled) {
1392
+ const nextEnabled = enabled === true;
1393
+ await writeWebuiSettings({ remoteAuthEnabled: nextEnabled });
1394
+ persistedRemoteAuthEnabled = nextEnabled;
1395
+ return persistedRemoteAuthEnabled;
1396
+ }
1397
+
1190
1398
  function parseCliScopedModelPatterns() {
1191
1399
  for (let index = 0; index < options.piArgs.length; index++) {
1192
1400
  const arg = options.piArgs[index];
@@ -6461,11 +6669,17 @@ async function createInitialTabs() {
6461
6669
  }
6462
6670
 
6463
6671
  const serverStartedAt = new Date().toISOString();
6672
+ let persistedRemoteAuthEnabled = await readPersistedRemoteAuthEnabled();
6464
6673
  const initialTabs = await createInitialTabs();
6465
6674
  const initialTab = initialTabs[0];
6466
6675
  let currentHost = options.host;
6467
6676
  let networkRebindInProgress = false;
6468
6677
  let networkRebindTargetHost = null;
6678
+ const remoteAuth = {
6679
+ pin: undefined,
6680
+ token: undefined,
6681
+ tokenExpiresAt: 0,
6682
+ };
6469
6683
 
6470
6684
  function localNetworkAddresses() {
6471
6685
  const addresses = [];
@@ -6478,7 +6692,52 @@ function localNetworkAddresses() {
6478
6692
  return [...new Set(addresses)].sort();
6479
6693
  }
6480
6694
 
6481
- function networkStatus() {
6695
+ function remoteAuthRequired() {
6696
+ return !isLocalHost(currentHost) && !!remoteAuth.pin;
6697
+ }
6698
+
6699
+ function generateRemotePin() {
6700
+ return String(randomInt(0, 10_000)).padStart(4, "0");
6701
+ }
6702
+
6703
+ function enableRemoteAuth(reason = "network exposure") {
6704
+ remoteAuth.pin = generateRemotePin();
6705
+ remoteAuth.token = createHash("sha256").update(`${randomUUID()}:${remoteAuth.pin}:${Date.now()}`).digest("base64url");
6706
+ remoteAuth.tokenExpiresAt = Date.now() + 7 * 24 * 60 * 60 * 1000;
6707
+ console.warn(`Pi Web UI remote PIN for ${reason}: ${remoteAuth.pin}`);
6708
+ return remoteAuth.pin;
6709
+ }
6710
+
6711
+ function resetRemoteAuth() {
6712
+ remoteAuth.pin = undefined;
6713
+ remoteAuth.token = undefined;
6714
+ remoteAuth.tokenExpiresAt = 0;
6715
+ }
6716
+
6717
+ function remoteAuthPreferenceEnabled() {
6718
+ return persistedRemoteAuthEnabled === true;
6719
+ }
6720
+
6721
+ function remoteAuthStartupEnabled() {
6722
+ return options.remoteAuthExplicit ? options.remoteAuth === true : remoteAuthPreferenceEnabled();
6723
+ }
6724
+
6725
+ function remoteAuthStartupReason() {
6726
+ if (options.remoteAuthExplicit) return "startup option";
6727
+ return "saved setting";
6728
+ }
6729
+
6730
+ function remoteAuthStatus({ includePin = false } = {}) {
6731
+ const enabled = !!remoteAuth.pin;
6732
+ const status = {
6733
+ enabled,
6734
+ required: enabled && !isLocalHost(currentHost),
6735
+ };
6736
+ if (includePin && enabled) status.pin = remoteAuth.pin;
6737
+ return status;
6738
+ }
6739
+
6740
+ function networkStatus({ includeAuthPin = false } = {}) {
6482
6741
  const open = !isLocalHost(currentHost);
6483
6742
  const targetHost = networkRebindTargetHost || currentHost;
6484
6743
  const opening = networkRebindInProgress && !isLocalHost(targetHost);
@@ -6492,6 +6751,7 @@ function networkStatus() {
6492
6751
  port: options.port,
6493
6752
  localUrl: `http://127.0.0.1:${options.port}/`,
6494
6753
  networkUrls,
6754
+ auth: remoteAuthStatus({ includePin: includeAuthPin }),
6495
6755
  };
6496
6756
  }
6497
6757
 
@@ -6515,6 +6775,23 @@ function closeSseClientsForRebind(nextHost) {
6515
6775
  }
6516
6776
  }
6517
6777
 
6778
+ function closeSseClientsForRemoteAuthChange() {
6779
+ for (const tab of tabs.values()) {
6780
+ const authEvent = {
6781
+ type: "webui_remote_auth_changed",
6782
+ tabId: tab.id,
6783
+ tabTitle: tab.title,
6784
+ auth: remoteAuthStatus(),
6785
+ };
6786
+ recordEvent(authEvent);
6787
+ for (const client of tab.sseClients) {
6788
+ sendSse(client, authEvent);
6789
+ client.end();
6790
+ }
6791
+ tab.sseClients.clear();
6792
+ }
6793
+ }
6794
+
6518
6795
  function closeServerListener() {
6519
6796
  return new Promise((resolve, reject) => {
6520
6797
  if (!server.listening) {
@@ -6558,7 +6835,7 @@ function listenOn(host) {
6558
6835
 
6559
6836
  async function openToLocalNetwork() {
6560
6837
  const nextHost = "0.0.0.0";
6561
- if (!isLocalHost(currentHost) || networkRebindInProgress) return networkStatus();
6838
+ if (!isLocalHost(currentHost) || networkRebindInProgress) return networkStatus({ includeAuthPin: true });
6562
6839
 
6563
6840
  networkRebindInProgress = true;
6564
6841
  networkRebindTargetHost = nextHost;
@@ -6568,8 +6845,8 @@ async function openToLocalNetwork() {
6568
6845
  await closeServerListener();
6569
6846
  await listenOn(nextHost);
6570
6847
  currentHost = nextHost;
6571
- console.warn("WARNING: Web UI is now reachable from the local network and has no authentication.");
6572
- return networkStatus();
6848
+ console.warn(`WARNING: Web UI is now reachable from the local network${remoteAuth.pin ? " and requires the remote PIN for non-local clients" : " without remote PIN authentication"}.`);
6849
+ return networkStatus({ includeAuthPin: true });
6573
6850
  } catch (error) {
6574
6851
  console.error("Failed to open Web UI to local network:", sanitizeError(error));
6575
6852
  if (!server.listening) {
@@ -6598,8 +6875,9 @@ async function closeNetworkAccess() {
6598
6875
  await closeServerListener();
6599
6876
  await listenOn(nextHost);
6600
6877
  currentHost = nextHost;
6878
+ if (!remoteAuthPreferenceEnabled()) resetRemoteAuth();
6601
6879
  console.warn("Web UI network access closed; listening on localhost only.");
6602
- return networkStatus();
6880
+ return networkStatus({ includeAuthPin: true });
6603
6881
  } catch (error) {
6604
6882
  console.error("Failed to close Web UI network access:", sanitizeError(error));
6605
6883
  if (!server.listening) {
@@ -6616,6 +6894,8 @@ async function closeNetworkAccess() {
6616
6894
  }
6617
6895
  }
6618
6896
 
6897
+ if (remoteAuthStartupEnabled()) enableRemoteAuth(remoteAuthStartupReason());
6898
+
6619
6899
  async function safeRpcData(tab, command, timeoutMs = STATUS_RPC_TIMEOUT_MS) {
6620
6900
  try {
6621
6901
  const response = await tab.rpc.send(command, timeoutMs);
@@ -6693,9 +6973,9 @@ async function tabStatusDetails(tab) {
6693
6973
  };
6694
6974
  }
6695
6975
 
6696
- async function webuiStatus({ detailed = false, eventLimit = 40 } = {}) {
6976
+ async function webuiStatus({ detailed = false, eventLimit = 40, includeAuthPin = false } = {}) {
6697
6977
  const tab = firstTab();
6698
- const network = networkStatus();
6978
+ const network = networkStatus({ includeAuthPin });
6699
6979
  const statusTabs = listTabs();
6700
6980
  const data = {
6701
6981
  online: true,
@@ -6731,6 +7011,46 @@ const server = createServer(async (req, res) => {
6731
7011
  try {
6732
7012
  const url = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`);
6733
7013
 
7014
+ if (url.pathname === "/remote-auth" && req.method === "GET") {
7015
+ sendRemoteAuthPage(res, url.searchParams.get("return") || "/");
7016
+ return;
7017
+ }
7018
+
7019
+ if (url.pathname === "/api/remote-auth" && req.method === "GET") {
7020
+ sendJson(res, 200, { ok: true, data: { auth: remoteAuthStatus({ includePin: isLocalRequest(req) }), local: isLocalRequest(req) } });
7021
+ return;
7022
+ }
7023
+
7024
+ if (url.pathname === "/api/remote-auth" && req.method === "POST") {
7025
+ const body = await readJsonBody(req);
7026
+ const pin = String(body.pin || "").trim();
7027
+ if (!remoteAuth.pin) throw makeHttpError(400, "Remote PIN authentication is not enabled");
7028
+ if (!/^\d{4}$/.test(pin) || !safeTimingEqual(pin, remoteAuth.pin)) throw makeHttpError(403, "Incorrect remote PIN");
7029
+ sendJson(res, 200, { ok: true, data: { auth: remoteAuthStatus() } }, { "set-cookie": remoteAuthCookie() });
7030
+ return;
7031
+ }
7032
+
7033
+ if (shouldChallengeRemoteAuth(req, url)) {
7034
+ sendRemoteAuthRequired(req, res, url);
7035
+ return;
7036
+ }
7037
+
7038
+ if (url.pathname === "/api/remote-auth/settings" && req.method === "POST") {
7039
+ requireLocalhostRoute(req, url.pathname);
7040
+ const body = await readJsonBody(req);
7041
+ if (body.enabled === true) {
7042
+ enableRemoteAuth("side panel toggle");
7043
+ await saveRemoteAuthPreference(true);
7044
+ } else if (body.enabled === false) {
7045
+ resetRemoteAuth();
7046
+ await saveRemoteAuthPreference(false);
7047
+ } else throw makeHttpError(400, "enabled must be true or false");
7048
+ closeSseClientsForRemoteAuthChange();
7049
+ const headers = body.enabled === false ? { "set-cookie": clearRemoteAuthCookie() } : {};
7050
+ sendJson(res, 200, { ok: true, data: { auth: remoteAuthStatus({ includePin: true }), network: networkStatus({ includeAuthPin: true }) } }, headers);
7051
+ return;
7052
+ }
7053
+
6734
7054
  if (url.pathname === "/api/tabs" && req.method === "GET") {
6735
7055
  sendJson(res, 200, { ok: true, data: { tabs: await listTabsWithReconciledActivity() } });
6736
7056
  return;
@@ -6800,7 +7120,7 @@ const server = createServer(async (req, res) => {
6800
7120
  }
6801
7121
 
6802
7122
  if (url.pathname === "/api/health" && req.method === "GET") {
6803
- const status = await webuiStatus();
7123
+ const status = await webuiStatus({ includeAuthPin: isLocalRequest(req) });
6804
7124
  sendJson(res, 200, {
6805
7125
  ok: true,
6806
7126
  webuiVersion: status.webuiVersion,
@@ -6821,7 +7141,7 @@ const server = createServer(async (req, res) => {
6821
7141
  const detailed = ["1", "true", "yes", "detailed"].includes(String(url.searchParams.get("detailed") || "").toLowerCase());
6822
7142
  const parsedEventLimit = Number.parseInt(url.searchParams.get("events") || "40", 10);
6823
7143
  const eventLimit = Number.isFinite(parsedEventLimit) ? parsedEventLimit : 40;
6824
- sendJson(res, 200, { ok: true, data: await webuiStatus({ detailed, eventLimit }) });
7144
+ sendJson(res, 200, { ok: true, data: await webuiStatus({ detailed, eventLimit, includeAuthPin: isLocalRequest(req) }) });
6825
7145
  return;
6826
7146
  }
6827
7147
 
@@ -6858,13 +7178,13 @@ const server = createServer(async (req, res) => {
6858
7178
  }
6859
7179
 
6860
7180
  if (url.pathname === "/api/network" && req.method === "GET") {
6861
- sendJson(res, 200, { ok: true, data: networkStatus() });
7181
+ sendJson(res, 200, { ok: true, data: networkStatus({ includeAuthPin: isLocalRequest(req) }) });
6862
7182
  return;
6863
7183
  }
6864
7184
 
6865
7185
  if (url.pathname === "/api/network/open" && req.method === "POST") {
6866
7186
  requireLocalhostRoute(req, url.pathname);
6867
- const before = networkStatus();
7187
+ const before = networkStatus({ includeAuthPin: true });
6868
7188
  const shouldOpen = !before.open && !networkRebindInProgress;
6869
7189
  sendJson(res, 202, { ok: true, data: { ...before, opening: shouldOpen || before.opening, closing: before.closing } }, { connection: "close" });
6870
7190
  if (shouldOpen) {
@@ -6875,7 +7195,7 @@ const server = createServer(async (req, res) => {
6875
7195
 
6876
7196
  if (url.pathname === "/api/network/close" && req.method === "POST") {
6877
7197
  requireLocalhostRoute(req, url.pathname);
6878
- const before = networkStatus();
7198
+ const before = networkStatus({ includeAuthPin: true });
6879
7199
  const shouldClose = before.open && !networkRebindInProgress;
6880
7200
  sendJson(res, 202, { ok: true, data: { ...before, opening: before.opening, closing: shouldClose || before.closing } }, { connection: "close" });
6881
7201
  if (shouldClose) {
@@ -7363,7 +7683,7 @@ server.listen(options.port, currentHost, () => {
7363
7683
  else console.log("Pi RPC: waiting for CWD selection in the Web UI");
7364
7684
  if (restoreTabs.length) console.log(`Restored Web UI tabs: ${initialTabs.length}`);
7365
7685
  if (!isLocalHost(currentHost)) {
7366
- console.warn("WARNING: Web UI has no authentication. Only expose it on trusted networks.");
7686
+ console.warn(`WARNING: Web UI is exposed to the network. Remote PIN auth is ${remoteAuth.pin ? "enabled" : "OFF"}; only expose it on trusted networks.`);
7367
7687
  }
7368
7688
  });
7369
7689