@firstpick/pi-package-webui 0.4.1 → 0.4.2

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; enable it in **Controls → Network → Remote PIN auth** before exposing it on trusted networks.
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,7 @@ 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
119
126
 
120
127
  ## Main features
121
128
 
@@ -143,6 +150,7 @@ Useful browser endpoints exposed by the local server include:
143
150
  - `GET /api/optional-features` for optional companion package install/update status.
144
151
  - `POST /api/optional-feature-install` for installing or updating known optional companion packages from the side panel.
145
152
  - `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.
153
+ - `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
154
 
147
155
  For local development, run the checkout helper directly, for example:
148
156
 
@@ -197,8 +205,11 @@ This requires `/git-staged-msg` and `/pr` from `@firstpick/pi-prompts-git-pr`; b
197
205
 
198
206
  - Default bind is localhost-only: `127.0.0.1:31415`.
199
207
  - 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.
208
+ - The side-panel **Remote PIN auth** toggle is off by default. When enabled, the server generates a random 4-digit PIN, shows it in Controls and `/webui-status`, and requires it from non-local browser clients.
209
+ - Localhost clients stay frictionless and can toggle Remote PIN auth; changing the toggle disconnects existing event streams so remote clients must re-authenticate after enablement.
210
+ - `--host 0.0.0.0` also exposes the Web UI to the local network; pass `--remote-auth` to start with PIN auth already enabled.
211
+ - 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.
212
+ - Remote PIN auth is a simple trusted-LAN HTTP gate, not hardened multi-user authentication; do not expose it to untrusted networks.
202
213
  - The Web UI update endpoint is restricted to localhost, because it runs package update commands and restarts the server.
203
214
  - Treat Pi Web UI as a local companion, not a hardened multi-user web service.
204
215
 
@@ -207,4 +218,5 @@ This requires `/git-staged-msg` and `/pr` from `@firstpick/pi-prompts-git-pr`; b
207
218
  - **`/webui-start` is missing:** restart Pi after installing the package.
208
219
  - **Wrong port or existing server:** use `/webui-status detailed`, or start on another port with `/webui-start --port 31500`.
209
220
  - **Optional feature is disabled or missing:** check the side panel, install the companion package if needed, then run `/reload` in the active Pi tab.
221
+ - **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
222
  - **PWA install or notifications are unavailable:** use `localhost` or HTTPS; browser support varies on LAN HTTP URLs.
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";
@@ -249,6 +249,8 @@ Options:
249
249
  --pi <command> Pi executable to spawn (default: bundled dependency, then "pi")
250
250
  --no-session Start Pi RPC with --no-session
251
251
  --name <name> Initial Web UI tab display name
252
+ --remote-auth Enable startup PIN authentication for non-local clients
253
+ --no-remote-auth Disable startup PIN authentication
252
254
  -h, --help Show this help
253
255
  -v, --version Print version
254
256
 
@@ -262,8 +264,9 @@ Examples:
262
264
  PI_WEBUI_PI_BIN=/path/to/pi pi-webui --no-session
263
265
 
264
266
  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.
267
+ The web UI controls Pi tools. It binds to localhost by default. Remote PIN
268
+ authentication is off by default; enable it in Controls or with --remote-auth
269
+ before exposing the server on trusted networks.
267
270
  `);
268
271
  }
269
272
 
@@ -285,6 +288,7 @@ function parseArgs(argv) {
285
288
  piBinExplicit: !!process.env.PI_WEBUI_PI_BIN,
286
289
  noSession: false,
287
290
  name: undefined,
291
+ remoteAuth: isTruthyEnv(process.env.PI_WEBUI_REMOTE_AUTH),
288
292
  piArgs: [],
289
293
  help: false,
290
294
  version: false,
@@ -339,6 +343,14 @@ function parseArgs(argv) {
339
343
  i++;
340
344
  continue;
341
345
  }
346
+ if (arg === "--remote-auth") {
347
+ options.remoteAuth = true;
348
+ continue;
349
+ }
350
+ if (arg === "--no-remote-auth") {
351
+ options.remoteAuth = false;
352
+ continue;
353
+ }
342
354
  throw new Error(`Unknown option: ${arg}. Pass Pi CLI args after --.`);
343
355
  }
344
356
 
@@ -649,6 +661,139 @@ async function readJsonBody(req, { limitBytes = BODY_LIMIT_BYTES } = {}) {
649
661
  return JSON.parse(text);
650
662
  }
651
663
 
664
+ function parseCookieHeader(header = "") {
665
+ const cookies = new Map();
666
+ for (const part of String(header || "").split(";")) {
667
+ const index = part.indexOf("=");
668
+ if (index === -1) continue;
669
+ const name = part.slice(0, index).trim();
670
+ const value = part.slice(index + 1).trim();
671
+ if (name) {
672
+ try {
673
+ cookies.set(name, decodeURIComponent(value));
674
+ } catch {
675
+ cookies.set(name, value);
676
+ }
677
+ }
678
+ }
679
+ return cookies;
680
+ }
681
+
682
+ function safeTimingEqual(a = "", b = "") {
683
+ const left = Buffer.from(String(a));
684
+ const right = Buffer.from(String(b));
685
+ return left.length === right.length && timingSafeEqual(left, right);
686
+ }
687
+
688
+ function safeReturnPath(value) {
689
+ const text = String(value || "/").trim();
690
+ if (!text.startsWith("/") || text.startsWith("//")) return "/";
691
+ return text;
692
+ }
693
+
694
+ function remoteAuthCookie(token = remoteAuth.token) {
695
+ const maxAge = Math.max(0, Math.floor((remoteAuth.tokenExpiresAt - Date.now()) / 1000));
696
+ return `pi_remote_auth=${encodeURIComponent(token || "")}; HttpOnly; SameSite=Lax; Path=/; Max-Age=${maxAge}`;
697
+ }
698
+
699
+ function clearRemoteAuthCookie() {
700
+ return "pi_remote_auth=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0";
701
+ }
702
+
703
+ function requestHasRemoteAuth(req) {
704
+ if (!remoteAuthRequired()) return true;
705
+ const token = parseCookieHeader(req.headers.cookie).get("pi_remote_auth");
706
+ return !!(token && remoteAuth.token && remoteAuth.tokenExpiresAt > Date.now() && safeTimingEqual(token, remoteAuth.token));
707
+ }
708
+
709
+ function isRemoteAuthPublicPath(pathname) {
710
+ return pathname === "/remote-auth" || pathname === "/api/remote-auth" || pathname === "/favicon.svg";
711
+ }
712
+
713
+ function shouldChallengeRemoteAuth(req, url) {
714
+ if (isLocalRequest(req) || !remoteAuthRequired() || isRemoteAuthPublicPath(url.pathname)) return false;
715
+ return !requestHasRemoteAuth(req);
716
+ }
717
+
718
+ function sendRemoteAuthPage(res, returnPath = "/") {
719
+ const safeReturn = safeReturnPath(returnPath);
720
+ const body = `<!doctype html>
721
+ <html lang="en">
722
+ <head>
723
+ <meta charset="utf-8">
724
+ <meta name="viewport" content="width=device-width, initial-scale=1">
725
+ <title>Pi Web UI Remote PIN</title>
726
+ <style>
727
+ :root { color-scheme: dark; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #0f172a; color: #e5e7eb; }
728
+ body { min-height: 100vh; display: grid; place-items: center; margin: 0; padding: 24px; box-sizing: border-box; }
729
+ 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); }
730
+ h1 { margin: 0 0 8px; font-size: 1.45rem; }
731
+ p { margin: 0 0 20px; color: #94a3b8; line-height: 1.5; }
732
+ label { display: block; margin-bottom: 8px; color: #cbd5e1; font-weight: 650; }
733
+ 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; }
734
+ 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; }
735
+ button:disabled { opacity: 0.65; cursor: wait; }
736
+ .error { min-height: 1.4em; margin-top: 14px; color: #fca5a5; }
737
+ </style>
738
+ </head>
739
+ <body>
740
+ <main>
741
+ <h1>Remote PIN required</h1>
742
+ <p>Enter the 4-digit PIN shown in the local Pi terminal or local Web UI to continue.</p>
743
+ <form id="pinForm" autocomplete="off">
744
+ <label for="pin">PIN</label>
745
+ <input id="pin" name="pin" inputmode="numeric" pattern="[0-9]{4}" maxlength="4" autofocus required>
746
+ <button id="submit" type="submit">Unlock Web UI</button>
747
+ <div id="error" class="error" role="alert"></div>
748
+ </form>
749
+ </main>
750
+ <script>
751
+ const returnPath = ${JSON.stringify(safeReturn).replace(/</g, "\\u003c")};
752
+ const form = document.getElementById("pinForm");
753
+ const input = document.getElementById("pin");
754
+ const button = document.getElementById("submit");
755
+ const error = document.getElementById("error");
756
+ input.addEventListener("input", () => { input.value = input.value.replace(/\\D/g, "").slice(0, 4); error.textContent = ""; });
757
+ form.addEventListener("submit", async (event) => {
758
+ event.preventDefault();
759
+ button.disabled = true;
760
+ error.textContent = "";
761
+ try {
762
+ const response = await fetch("/api/remote-auth", {
763
+ method: "POST",
764
+ headers: { "content-type": "application/json" },
765
+ body: JSON.stringify({ pin: input.value }),
766
+ });
767
+ const data = await response.json().catch(() => ({}));
768
+ if (!response.ok || data.ok !== true) throw new Error(data.error || "Incorrect PIN");
769
+ window.location.replace(returnPath || "/");
770
+ } catch (err) {
771
+ error.textContent = err?.message || String(err);
772
+ input.select();
773
+ } finally {
774
+ button.disabled = false;
775
+ }
776
+ });
777
+ </script>
778
+ </body>
779
+ </html>`;
780
+ res.writeHead(200, {
781
+ "content-type": "text/html; charset=utf-8",
782
+ "cache-control": "no-store",
783
+ "x-content-type-options": "nosniff",
784
+ });
785
+ res.end(body);
786
+ }
787
+
788
+ function sendRemoteAuthRequired(req, res, url) {
789
+ const acceptsHtml = String(req.headers.accept || "").includes("text/html");
790
+ if (req.method === "GET" && (acceptsHtml || url.pathname === "/" || url.pathname === "/index.html" || url.pathname === "/remote-auth")) {
791
+ sendRemoteAuthPage(res, `${url.pathname}${url.search || ""}`);
792
+ return;
793
+ }
794
+ sendJson(res, 401, { ok: false, error: "Remote PIN required", remoteAuthRequired: true }, { "www-authenticate": "PiRemotePin" });
795
+ }
796
+
652
797
  function sendSse(res, event) {
653
798
  res.write(`data: ${JSON.stringify(event)}\n\n`);
654
799
  }
@@ -6466,6 +6611,11 @@ const initialTab = initialTabs[0];
6466
6611
  let currentHost = options.host;
6467
6612
  let networkRebindInProgress = false;
6468
6613
  let networkRebindTargetHost = null;
6614
+ const remoteAuth = {
6615
+ pin: undefined,
6616
+ token: undefined,
6617
+ tokenExpiresAt: 0,
6618
+ };
6469
6619
 
6470
6620
  function localNetworkAddresses() {
6471
6621
  const addresses = [];
@@ -6478,7 +6628,39 @@ function localNetworkAddresses() {
6478
6628
  return [...new Set(addresses)].sort();
6479
6629
  }
6480
6630
 
6481
- function networkStatus() {
6631
+ function remoteAuthRequired() {
6632
+ return !isLocalHost(currentHost) && !!remoteAuth.pin;
6633
+ }
6634
+
6635
+ function generateRemotePin() {
6636
+ return String(randomInt(0, 10_000)).padStart(4, "0");
6637
+ }
6638
+
6639
+ function enableRemoteAuth(reason = "network exposure") {
6640
+ remoteAuth.pin = generateRemotePin();
6641
+ remoteAuth.token = createHash("sha256").update(`${randomUUID()}:${remoteAuth.pin}:${Date.now()}`).digest("base64url");
6642
+ remoteAuth.tokenExpiresAt = Date.now() + 7 * 24 * 60 * 60 * 1000;
6643
+ console.warn(`Pi Web UI remote PIN for ${reason}: ${remoteAuth.pin}`);
6644
+ return remoteAuth.pin;
6645
+ }
6646
+
6647
+ function resetRemoteAuth() {
6648
+ remoteAuth.pin = undefined;
6649
+ remoteAuth.token = undefined;
6650
+ remoteAuth.tokenExpiresAt = 0;
6651
+ }
6652
+
6653
+ function remoteAuthStatus({ includePin = false } = {}) {
6654
+ const enabled = !!remoteAuth.pin;
6655
+ const status = {
6656
+ enabled,
6657
+ required: enabled && !isLocalHost(currentHost),
6658
+ };
6659
+ if (includePin && enabled) status.pin = remoteAuth.pin;
6660
+ return status;
6661
+ }
6662
+
6663
+ function networkStatus({ includeAuthPin = false } = {}) {
6482
6664
  const open = !isLocalHost(currentHost);
6483
6665
  const targetHost = networkRebindTargetHost || currentHost;
6484
6666
  const opening = networkRebindInProgress && !isLocalHost(targetHost);
@@ -6492,6 +6674,7 @@ function networkStatus() {
6492
6674
  port: options.port,
6493
6675
  localUrl: `http://127.0.0.1:${options.port}/`,
6494
6676
  networkUrls,
6677
+ auth: remoteAuthStatus({ includePin: includeAuthPin }),
6495
6678
  };
6496
6679
  }
6497
6680
 
@@ -6515,6 +6698,23 @@ function closeSseClientsForRebind(nextHost) {
6515
6698
  }
6516
6699
  }
6517
6700
 
6701
+ function closeSseClientsForRemoteAuthChange() {
6702
+ for (const tab of tabs.values()) {
6703
+ const authEvent = {
6704
+ type: "webui_remote_auth_changed",
6705
+ tabId: tab.id,
6706
+ tabTitle: tab.title,
6707
+ auth: remoteAuthStatus(),
6708
+ };
6709
+ recordEvent(authEvent);
6710
+ for (const client of tab.sseClients) {
6711
+ sendSse(client, authEvent);
6712
+ client.end();
6713
+ }
6714
+ tab.sseClients.clear();
6715
+ }
6716
+ }
6717
+
6518
6718
  function closeServerListener() {
6519
6719
  return new Promise((resolve, reject) => {
6520
6720
  if (!server.listening) {
@@ -6558,7 +6758,7 @@ function listenOn(host) {
6558
6758
 
6559
6759
  async function openToLocalNetwork() {
6560
6760
  const nextHost = "0.0.0.0";
6561
- if (!isLocalHost(currentHost) || networkRebindInProgress) return networkStatus();
6761
+ if (!isLocalHost(currentHost) || networkRebindInProgress) return networkStatus({ includeAuthPin: true });
6562
6762
 
6563
6763
  networkRebindInProgress = true;
6564
6764
  networkRebindTargetHost = nextHost;
@@ -6568,8 +6768,8 @@ async function openToLocalNetwork() {
6568
6768
  await closeServerListener();
6569
6769
  await listenOn(nextHost);
6570
6770
  currentHost = nextHost;
6571
- console.warn("WARNING: Web UI is now reachable from the local network and has no authentication.");
6572
- return networkStatus();
6771
+ 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"}.`);
6772
+ return networkStatus({ includeAuthPin: true });
6573
6773
  } catch (error) {
6574
6774
  console.error("Failed to open Web UI to local network:", sanitizeError(error));
6575
6775
  if (!server.listening) {
@@ -6598,6 +6798,7 @@ async function closeNetworkAccess() {
6598
6798
  await closeServerListener();
6599
6799
  await listenOn(nextHost);
6600
6800
  currentHost = nextHost;
6801
+ resetRemoteAuth();
6601
6802
  console.warn("Web UI network access closed; listening on localhost only.");
6602
6803
  return networkStatus();
6603
6804
  } catch (error) {
@@ -6616,6 +6817,8 @@ async function closeNetworkAccess() {
6616
6817
  }
6617
6818
  }
6618
6819
 
6820
+ if (!isLocalHost(currentHost) && options.remoteAuth !== false) enableRemoteAuth("startup network listener");
6821
+
6619
6822
  async function safeRpcData(tab, command, timeoutMs = STATUS_RPC_TIMEOUT_MS) {
6620
6823
  try {
6621
6824
  const response = await tab.rpc.send(command, timeoutMs);
@@ -6693,9 +6896,9 @@ async function tabStatusDetails(tab) {
6693
6896
  };
6694
6897
  }
6695
6898
 
6696
- async function webuiStatus({ detailed = false, eventLimit = 40 } = {}) {
6899
+ async function webuiStatus({ detailed = false, eventLimit = 40, includeAuthPin = false } = {}) {
6697
6900
  const tab = firstTab();
6698
- const network = networkStatus();
6901
+ const network = networkStatus({ includeAuthPin });
6699
6902
  const statusTabs = listTabs();
6700
6903
  const data = {
6701
6904
  online: true,
@@ -6731,6 +6934,42 @@ const server = createServer(async (req, res) => {
6731
6934
  try {
6732
6935
  const url = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`);
6733
6936
 
6937
+ if (url.pathname === "/remote-auth" && req.method === "GET") {
6938
+ sendRemoteAuthPage(res, url.searchParams.get("return") || "/");
6939
+ return;
6940
+ }
6941
+
6942
+ if (url.pathname === "/api/remote-auth" && req.method === "GET") {
6943
+ sendJson(res, 200, { ok: true, data: { auth: remoteAuthStatus({ includePin: isLocalRequest(req) }), local: isLocalRequest(req) } });
6944
+ return;
6945
+ }
6946
+
6947
+ if (url.pathname === "/api/remote-auth" && req.method === "POST") {
6948
+ const body = await readJsonBody(req);
6949
+ const pin = String(body.pin || "").trim();
6950
+ if (!remoteAuth.pin) throw makeHttpError(400, "Remote PIN authentication is not enabled");
6951
+ if (!/^\d{4}$/.test(pin) || !safeTimingEqual(pin, remoteAuth.pin)) throw makeHttpError(403, "Incorrect remote PIN");
6952
+ sendJson(res, 200, { ok: true, data: { auth: remoteAuthStatus() } }, { "set-cookie": remoteAuthCookie() });
6953
+ return;
6954
+ }
6955
+
6956
+ if (shouldChallengeRemoteAuth(req, url)) {
6957
+ sendRemoteAuthRequired(req, res, url);
6958
+ return;
6959
+ }
6960
+
6961
+ if (url.pathname === "/api/remote-auth/settings" && req.method === "POST") {
6962
+ requireLocalhostRoute(req, url.pathname);
6963
+ const body = await readJsonBody(req);
6964
+ if (body.enabled === true) enableRemoteAuth("side panel toggle");
6965
+ else if (body.enabled === false) resetRemoteAuth();
6966
+ else throw makeHttpError(400, "enabled must be true or false");
6967
+ closeSseClientsForRemoteAuthChange();
6968
+ const headers = body.enabled === false ? { "set-cookie": clearRemoteAuthCookie() } : {};
6969
+ sendJson(res, 200, { ok: true, data: { auth: remoteAuthStatus({ includePin: true }), network: networkStatus({ includeAuthPin: true }) } }, headers);
6970
+ return;
6971
+ }
6972
+
6734
6973
  if (url.pathname === "/api/tabs" && req.method === "GET") {
6735
6974
  sendJson(res, 200, { ok: true, data: { tabs: await listTabsWithReconciledActivity() } });
6736
6975
  return;
@@ -6800,7 +7039,7 @@ const server = createServer(async (req, res) => {
6800
7039
  }
6801
7040
 
6802
7041
  if (url.pathname === "/api/health" && req.method === "GET") {
6803
- const status = await webuiStatus();
7042
+ const status = await webuiStatus({ includeAuthPin: isLocalRequest(req) });
6804
7043
  sendJson(res, 200, {
6805
7044
  ok: true,
6806
7045
  webuiVersion: status.webuiVersion,
@@ -6821,7 +7060,7 @@ const server = createServer(async (req, res) => {
6821
7060
  const detailed = ["1", "true", "yes", "detailed"].includes(String(url.searchParams.get("detailed") || "").toLowerCase());
6822
7061
  const parsedEventLimit = Number.parseInt(url.searchParams.get("events") || "40", 10);
6823
7062
  const eventLimit = Number.isFinite(parsedEventLimit) ? parsedEventLimit : 40;
6824
- sendJson(res, 200, { ok: true, data: await webuiStatus({ detailed, eventLimit }) });
7063
+ sendJson(res, 200, { ok: true, data: await webuiStatus({ detailed, eventLimit, includeAuthPin: isLocalRequest(req) }) });
6825
7064
  return;
6826
7065
  }
6827
7066
 
@@ -6858,13 +7097,13 @@ const server = createServer(async (req, res) => {
6858
7097
  }
6859
7098
 
6860
7099
  if (url.pathname === "/api/network" && req.method === "GET") {
6861
- sendJson(res, 200, { ok: true, data: networkStatus() });
7100
+ sendJson(res, 200, { ok: true, data: networkStatus({ includeAuthPin: isLocalRequest(req) }) });
6862
7101
  return;
6863
7102
  }
6864
7103
 
6865
7104
  if (url.pathname === "/api/network/open" && req.method === "POST") {
6866
7105
  requireLocalhostRoute(req, url.pathname);
6867
- const before = networkStatus();
7106
+ const before = networkStatus({ includeAuthPin: true });
6868
7107
  const shouldOpen = !before.open && !networkRebindInProgress;
6869
7108
  sendJson(res, 202, { ok: true, data: { ...before, opening: shouldOpen || before.opening, closing: before.closing } }, { connection: "close" });
6870
7109
  if (shouldOpen) {
@@ -6875,7 +7114,7 @@ const server = createServer(async (req, res) => {
6875
7114
 
6876
7115
  if (url.pathname === "/api/network/close" && req.method === "POST") {
6877
7116
  requireLocalhostRoute(req, url.pathname);
6878
- const before = networkStatus();
7117
+ const before = networkStatus({ includeAuthPin: true });
6879
7118
  const shouldClose = before.open && !networkRebindInProgress;
6880
7119
  sendJson(res, 202, { ok: true, data: { ...before, opening: before.opening, closing: shouldClose || before.closing } }, { connection: "close" });
6881
7120
  if (shouldClose) {
@@ -7363,7 +7602,7 @@ server.listen(options.port, currentHost, () => {
7363
7602
  else console.log("Pi RPC: waiting for CWD selection in the Web UI");
7364
7603
  if (restoreTabs.length) console.log(`Restored Web UI tabs: ${initialTabs.length}`);
7365
7604
  if (!isLocalHost(currentHost)) {
7366
- console.warn("WARNING: Web UI has no authentication. Only expose it on trusted networks.");
7605
+ 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
7606
  }
7368
7607
  });
7369
7608
 
package/index.ts CHANGED
@@ -22,6 +22,7 @@ type WebuiAddress = {
22
22
  type StartWebuiOptions = WebuiAddress & {
23
23
  open: boolean;
24
24
  noSession: boolean;
25
+ remoteAuth: boolean;
25
26
  name?: string;
26
27
  piArgs: string[];
27
28
  };
@@ -104,6 +105,7 @@ function parseStartWebuiArgs(args: string): StartWebuiOptions {
104
105
  port: DEFAULT_PORT,
105
106
  open: true,
106
107
  noSession: false,
108
+ remoteAuth: false,
107
109
  piArgs: [],
108
110
  };
109
111
  const tokens = tokenizeArgs(args || "");
@@ -122,6 +124,14 @@ function parseStartWebuiArgs(args: string): StartWebuiOptions {
122
124
  options.noSession = true;
123
125
  continue;
124
126
  }
127
+ if (token === "--remote-auth") {
128
+ options.remoteAuth = true;
129
+ continue;
130
+ }
131
+ if (token === "--no-remote-auth") {
132
+ options.remoteAuth = false;
133
+ continue;
134
+ }
125
135
  if (token === "--host") {
126
136
  options.host = takeValue(tokens, i, token);
127
137
  i++;
@@ -516,6 +526,7 @@ function waitForWebuiUrl(child: WebuiChild, timeoutMs = START_TIMEOUT_MS): Promi
516
526
  async function startWebui(options: StartWebuiOptions, ctx: ExtensionCommandContext, restoreTabs: RestorableWebuiTab[] = []): Promise<string> {
517
527
  const args = [webuiBin, "--host", options.host, "--port", String(options.port), "--cwd", ctx.cwd];
518
528
  if (options.noSession) args.push("--no-session");
529
+ if (options.remoteAuth) args.push("--remote-auth");
519
530
  if (options.name) args.push("--name", options.name);
520
531
  if (options.piArgs.length > 0) args.push("--", ...options.piArgs);
521
532
 
@@ -688,8 +699,10 @@ function formatWebuiStatus(result: WebuiStatusFetchResult, requestedDetailed: bo
688
699
  const network = data.network || {};
689
700
  const tabs = Array.isArray(data.tabs) ? data.tabs : [];
690
701
  const networkUrls = Array.isArray(network.networkUrls) ? network.networkUrls : [];
702
+ const auth = network.auth || {};
691
703
  const pageUrl = data.pageUrl || network.localUrl || result.url;
692
704
  const networkLabel = network.open ? `open to LAN${network.opening ? " (opening)" : ""}` : network.opening ? "opening" : "local only";
705
+ const authLabel = auth.enabled ? `remote PIN on${auth.pin ? ` · PIN ${auth.pin}` : ""}` : "remote PIN off";
693
706
 
694
707
  if (!requestedDetailed) {
695
708
  const lines = [
@@ -698,6 +711,7 @@ function formatWebuiStatus(result: WebuiStatusFetchResult, requestedDetailed: bo
698
711
  detailLine("URL", pageUrl),
699
712
  detailLine("Online", "yes"),
700
713
  detailLine("Network", networkLabel),
714
+ detailLine("Auth", authLabel),
701
715
  detailLine("Tabs", tabs.length || "?"),
702
716
  ];
703
717
  if (networkUrls.length) lines.push(detailLine("LAN URLs", networkUrls.join(", ")));
@@ -712,6 +726,7 @@ function formatWebuiStatus(result: WebuiStatusFetchResult, requestedDetailed: bo
712
726
  detailLine("URL", pageUrl),
713
727
  detailLine("Online", "yes"),
714
728
  detailLine("Network", networkLabel),
729
+ detailLine("Auth", authLabel),
715
730
  detailLine("Bind", `${data.boundHost || network.host || "unknown"}:${data.port || network.port || "?"}`),
716
731
  detailLine("Version", data.webuiVersion || "unknown"),
717
732
  detailLine("PIDs", `webui ${data.webuiPid || "unknown"} · pi ${data.piPid || "unknown"}`),
@@ -758,7 +773,7 @@ function formatWebuiStatus(result: WebuiStatusFetchResult, requestedDetailed: bo
758
773
 
759
774
  function usage(): string {
760
775
  return [
761
- "Usage: /webui-start [port] [--port N] [--no-open] [--no-session] [--name NAME] [-- --model provider/model]",
776
+ "Usage: /webui-start [port] [--port N] [--no-open] [--no-session] [--remote-auth] [--name NAME] [-- --model provider/model]",
762
777
  "Starts the Pi Web UI companion server for the current cwd, prints the localhost URL, and opens it in your default browser.",
763
778
  ].join("\n");
764
779
  }
@@ -12,6 +12,7 @@ export const TRUST_GUARD_TYPES = new Set([
12
12
  export const LOCALHOST_ONLY_POST_ROUTES = new Map([
13
13
  ["/api/network/open", "Opening to the network is only allowed from localhost"],
14
14
  ["/api/network/close", "Closing network access is only allowed from localhost"],
15
+ ["/api/remote-auth/settings", "Remote PIN authentication settings are only allowed from localhost"],
15
16
  ["/api/restart", "Restart is only allowed from localhost"],
16
17
  ["/api/update", "Updating Pi from the Web UI is only allowed from localhost"],
17
18
  ["/api/shutdown", "Shutdown is only allowed from localhost"],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@firstpick/pi-package-webui",
3
- "version": "0.4.1",
3
+ "version": "0.4.2",
4
4
  "description": "Pi Web UI companion package with a local browser UI CLI plus /webui-start and /webui-status commands.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/Firstp1ck/npm-packages/tree/main/pi-package-webui#readme",
package/public/app.js CHANGED
@@ -127,6 +127,8 @@ const elements = {
127
127
  backgroundClearButton: $("#backgroundClearButton"),
128
128
  backgroundStatus: $("#backgroundStatus"),
129
129
  networkStatus: $("#networkStatus"),
130
+ remoteAuthToggle: $("#remoteAuthToggle"),
131
+ remoteAuthStatus: $("#remoteAuthStatus"),
130
132
  openNetworkButton: $("#openNetworkButton"),
131
133
  serverActionSelect: $("#serverActionSelect"),
132
134
  runServerActionButton: $("#runServerActionButton"),
@@ -2267,6 +2269,10 @@ async function api(path, { method = "GET", body, tabId = activeTabId, scoped = t
2267
2269
  setBackendOffline(false);
2268
2270
  const data = await response.json().catch(() => ({}));
2269
2271
  if (!response.ok) {
2272
+ if (response.status === 401 && data.remoteAuthRequired) {
2273
+ const returnPath = `${window.location.pathname}${window.location.search || ""}` || "/";
2274
+ window.location.assign(`/remote-auth?return=${encodeURIComponent(returnPath)}`);
2275
+ }
2270
2276
  const error = new Error(data.error || data.message || JSON.stringify(data));
2271
2277
  error.statusCode = response.status;
2272
2278
  error.data = data;
@@ -14838,7 +14844,22 @@ function renderNetworkStatus() {
14838
14844
  if (networkUrls.length === 0) list.append(make("div", "network-status-empty", "No LAN address detected."));
14839
14845
  }
14840
14846
 
14841
- elements.networkStatus.replaceChildren(heading, detail, list);
14847
+ const auth = network?.auth || {};
14848
+ const authText = auth.enabled
14849
+ ? auth.pin
14850
+ ? `Remote PIN auth on · PIN ${auth.pin}`
14851
+ : "Remote PIN auth on"
14852
+ : "Remote PIN auth off";
14853
+ const authDetail = make("div", "network-status-detail", authText);
14854
+
14855
+ elements.networkStatus.replaceChildren(heading, detail, list, authDetail);
14856
+ elements.remoteAuthToggle.checked = !!auth.enabled;
14857
+ elements.remoteAuthToggle.disabled = rebinding;
14858
+ elements.remoteAuthStatus.textContent = auth.enabled
14859
+ ? auth.pin
14860
+ ? `PIN ${auth.pin}`
14861
+ : "On"
14862
+ : "Off";
14842
14863
  elements.openNetworkButton.disabled = rebinding;
14843
14864
  elements.openNetworkButton.textContent = opening ? "Opening…" : closing ? "Closing…" : open ? "Close for network" : "Open to network";
14844
14865
  }
@@ -14854,6 +14875,28 @@ async function refreshNetworkStatus() {
14854
14875
  renderNetworkStatus();
14855
14876
  }
14856
14877
 
14878
+ async function toggleRemoteAuth() {
14879
+ const enable = !latestNetwork?.auth?.enabled;
14880
+ const message = enable
14881
+ ? "Enable remote PIN authentication?\n\nA random 4-digit PIN will be required for non-local browser clients. The PIN is shown in Controls."
14882
+ : "Disable remote PIN authentication?\n\nNon-local browser clients will no longer need a PIN while the network listener is open.";
14883
+ if (!confirm(message)) {
14884
+ renderNetworkStatus();
14885
+ return;
14886
+ }
14887
+
14888
+ elements.remoteAuthToggle.disabled = true;
14889
+ try {
14890
+ const response = await api("/api/remote-auth/settings", { method: "POST", body: { enabled: enable }, scoped: false });
14891
+ latestNetwork = response.data?.network || { ...(latestNetwork || {}), auth: response.data?.auth };
14892
+ addEvent(enable ? "remote PIN auth enabled" : "remote PIN auth disabled", enable ? "warn" : "info");
14893
+ } catch (error) {
14894
+ addEvent(error.message || String(error), "error");
14895
+ } finally {
14896
+ renderNetworkStatus();
14897
+ }
14898
+ }
14899
+
14857
14900
  async function refreshFooterData(tabContext = activeTabContext()) {
14858
14901
  if (!tabContext.tabId) return;
14859
14902
  await Promise.allSettled([refreshStats(tabContext), refreshWorkspace(tabContext)]);
@@ -15636,7 +15679,7 @@ async function openToNetwork() {
15636
15679
  await closeNetworkAccess();
15637
15680
  return;
15638
15681
  }
15639
- if (!confirm("Open Pi Web UI to your local network?\n\nThe Web UI has no authentication and can control Pi/tools. Only do this on a trusted LAN.")) return;
15682
+ if (!confirm(`Open Pi Web UI to your local network?\n\nRemote PIN auth is ${latestNetwork?.auth?.enabled ? "ON" : "OFF"}. The Web UI can control Pi/tools, so only do this on a trusted LAN.`)) return;
15640
15683
 
15641
15684
  elements.openNetworkButton.disabled = true;
15642
15685
  elements.openNetworkButton.textContent = "Opening…";
@@ -16374,6 +16417,11 @@ function handleEvent(event) {
16374
16417
  renderNetworkStatus();
16375
16418
  break;
16376
16419
  }
16420
+ case "webui_remote_auth_changed":
16421
+ latestNetwork = { ...(latestNetwork || {}), auth: event.auth || {} };
16422
+ addEvent(`remote PIN auth ${event.auth?.enabled ? "enabled" : "disabled"}`, event.auth?.enabled ? "warn" : "info");
16423
+ renderNetworkStatus();
16424
+ break;
16377
16425
  case "pi_process_exit":
16378
16426
  addEvent(`pi rpc exited (${event.code ?? event.signal ?? "unknown"})`, "error");
16379
16427
  clearRunIndicatorActivity();
@@ -17071,6 +17119,7 @@ if (elements.backgroundChooseButton && elements.backgroundInput) {
17071
17119
  if (elements.backgroundClearButton) {
17072
17120
  elements.backgroundClearButton.addEventListener("click", () => clearCustomBackground().catch((error) => addEvent(error.message || String(error), "error")));
17073
17121
  }
17122
+ elements.remoteAuthToggle.addEventListener("change", () => toggleRemoteAuth().catch((error) => addEvent(error.message || String(error), "error")));
17074
17123
  elements.openNetworkButton.addEventListener("click", openToNetwork);
17075
17124
  elements.serverActionSelect.addEventListener("change", updateServerActionButton);
17076
17125
  elements.runServerActionButton.addEventListener("click", () => runSelectedServerAction().catch((error) => addEvent(error.message || String(error), "error")));
package/public/index.html CHANGED
@@ -370,6 +370,13 @@
370
370
  <div class="control-field network-control-field">
371
371
  <label>Network</label>
372
372
  <div id="networkStatus" class="network-status closed">Local only</div>
373
+ <label class="toggle-control remote-auth-toggle" for="remoteAuthToggle">
374
+ <input id="remoteAuthToggle" type="checkbox" />
375
+ <span>
376
+ <span class="toggle-control-label">Remote PIN auth</span>
377
+ <span id="remoteAuthStatus" class="toggle-control-hint">Off</span>
378
+ </span>
379
+ </label>
373
380
  <button id="openNetworkButton" type="button">Open to network</button>
374
381
  </div>
375
382
  <div class="control-field server-control-field">
@@ -245,8 +245,15 @@ try {
245
245
  assert.equal(traversalDelete.status, 403, "session delete outside the session dir must return 403");
246
246
  assert.match(String(traversalDelete.body?.error || ""), /session directory/i);
247
247
 
248
+ const initialAuth = await request("127.0.0.1", "/api/remote-auth");
249
+ assert.equal(initialAuth.status, 200);
250
+ assert.equal(initialAuth.body?.data?.auth?.enabled, false, "remote PIN auth should be off by default");
251
+
248
252
  const lan = lanAddress();
249
253
  if (lan) {
254
+ const remoteHealthBeforeAuth = await request(lan, "/api/health");
255
+ assert.equal(remoteHealthBeforeAuth.status, 200, "LAN clients should connect without a PIN while auth is off");
256
+
250
257
  const remoteDelete = await request(lan, "/api/session-delete", {
251
258
  method: "POST",
252
259
  body: { sessionPath: path.join(cwd, "outside.jsonl"), confirmed: true, tab: tabId },
@@ -262,7 +269,55 @@ try {
262
269
 
263
270
  const remoteClose = await request(lan, "/api/network/close", { method: "POST" });
264
271
  assert.equal(remoteClose.status, 403, "network close must be localhost-only");
272
+
273
+ const enableAuth = await request("127.0.0.1", "/api/remote-auth/settings", { method: "POST", body: { enabled: true } });
274
+ assert.equal(enableAuth.status, 200, "localhost can enable remote PIN auth");
275
+ const pin = enableAuth.body?.data?.auth?.pin;
276
+ assert.match(pin, /^\d{4}$/, "enabling remote auth should generate a 4-digit PIN");
277
+
278
+ const remoteHealthWithAuth = await request(lan, "/api/health");
279
+ assert.equal(remoteHealthWithAuth.status, 401, "unauthenticated LAN clients should be challenged while remote auth is on");
280
+
281
+ const wrongPin = pin === "0000" ? "0001" : "0000";
282
+ const badLogin = await request(lan, "/api/remote-auth", { method: "POST", body: { pin: wrongPin } });
283
+ assert.equal(badLogin.status, 403, "wrong remote PIN should be rejected");
284
+
285
+ const loginResponse = await fetch(`http://${lan}:${port}/api/remote-auth`, {
286
+ method: "POST",
287
+ headers: { "content-type": "application/json" },
288
+ body: JSON.stringify({ pin }),
289
+ signal: AbortSignal.timeout(5_000),
290
+ });
291
+ assert.equal(loginResponse.status, 200, "correct remote PIN should be accepted");
292
+ const authCookie = loginResponse.headers.get("set-cookie")?.split(";", 1)[0];
293
+ assert.ok(authCookie, "remote auth login should set an auth cookie");
294
+
295
+ const authedHealth = await fetch(`http://${lan}:${port}/api/health`, {
296
+ headers: { cookie: authCookie },
297
+ signal: AbortSignal.timeout(5_000),
298
+ });
299
+ assert.equal(authedHealth.status, 200, "authenticated LAN client should reach guarded APIs");
300
+ await authedHealth.json();
301
+
302
+ const remoteSettings = await fetch(`http://${lan}:${port}/api/remote-auth/settings`, {
303
+ method: "POST",
304
+ headers: { "content-type": "application/json", cookie: authCookie },
305
+ body: JSON.stringify({ enabled: false }),
306
+ signal: AbortSignal.timeout(5_000),
307
+ });
308
+ assert.equal(remoteSettings.status, 403, "remote clients must not toggle remote PIN auth settings");
309
+ await remoteSettings.json().catch(() => undefined);
310
+
311
+ const disableAuth = await request("127.0.0.1", "/api/remote-auth/settings", { method: "POST", body: { enabled: false } });
312
+ assert.equal(disableAuth.status, 200, "localhost can disable remote PIN auth");
313
+ const remoteHealthAfterDisable = await request(lan, "/api/health");
314
+ assert.equal(remoteHealthAfterDisable.status, 200, "LAN clients should reconnect without a PIN after auth is disabled");
265
315
  } else {
316
+ const enableAuth = await request("127.0.0.1", "/api/remote-auth/settings", { method: "POST", body: { enabled: true } });
317
+ assert.equal(enableAuth.status, 200, "localhost can enable remote PIN auth");
318
+ assert.match(enableAuth.body?.data?.auth?.pin, /^\d{4}$/);
319
+ const disableAuth = await request("127.0.0.1", "/api/remote-auth/settings", { method: "POST", body: { enabled: false } });
320
+ assert.equal(disableAuth.status, 200, "localhost can disable remote PIN auth");
266
321
  console.log("http-endpoints-harness: no LAN address detected; skipping remote-client checks");
267
322
  }
268
323
 
@@ -399,6 +399,8 @@ assert.match(app, /initializeCustomBackground\(\)\.catch/, "startup should resto
399
399
  assert.match(app, /Restart Web UI to load themes/, "frontend should explain when a stale server cannot serve the themes endpoint");
400
400
  assert.match(app, /themeSelect\.addEventListener\("change"/, "side-panel theme selector should switch themes immediately");
401
401
  assert.match(app, /open \? "Close for network" : "Open to network"/, "network button should toggle from open to close action");
402
+ assert.match(app, /remoteAuthToggle: \$\("#remoteAuthToggle"\)/, "Controls should expose the remote PIN auth toggle");
403
+ assert.match(app, /api\("\/api\/remote-auth\/settings", \{ method: "POST"/, "remote PIN auth toggle should call the settings endpoint");
402
404
  assert.match(app, /api\("\/api\/network\/close", \{ method: "POST"/, "network close action should call the close endpoint");
403
405
  assert.match(app, /webuiVersionBadge: \$\("#webuiVersionBadge"\)/, "frontend should bind the Control Deck version badge");
404
406
  assert.match(app, /webuiDevBadge: \$\("#webuiDevBadge"\)/, "frontend should bind the Control Deck dev badge");
@@ -132,6 +132,10 @@ try {
132
132
  LOCALHOST_ONLY_POST_ROUTES.has("/api/network/close"),
133
133
  "closing network access must be localhost-only like opening it",
134
134
  );
135
+ assert.ok(
136
+ LOCALHOST_ONLY_POST_ROUTES.has("/api/remote-auth/settings"),
137
+ "remote PIN auth settings must be localhost-only",
138
+ );
135
139
  } finally {
136
140
  await rm(tempDir, { recursive: true, force: true });
137
141
  await rm(outsideDir, { recursive: true, force: true });