@firstpick/pi-package-webui 0.4.0 → 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/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
  }
@@ -933,6 +1078,10 @@ async function installRootDeclaresPackage(root, packageName) {
933
1078
  return declaredDependencySpec(pkg, packageName) !== undefined;
934
1079
  }
935
1080
 
1081
+ async function installRootContainsPackage(root, packageName) {
1082
+ return directoryExists(packageNodeModulesPath(path.join(root, "node_modules"), packageName));
1083
+ }
1084
+
936
1085
  function configuredAgentNpmRoot() {
937
1086
  const root = process.env.PI_CODING_AGENT_DIR ? path.resolve(expandUserPath(process.env.PI_CODING_AGENT_DIR)) : agentDir;
938
1087
  return path.join(root, "npm");
@@ -943,10 +1092,10 @@ async function optionalDependencyInstallRoot() {
943
1092
  if (configuredRoot) return path.resolve(expandUserPath(configuredRoot));
944
1093
 
945
1094
  const installRoot = nodeModulesParentForPackageRoot(packageRoot);
946
- if (await installRootDeclaresPackage(installRoot, "@firstpick/pi-package-webui")) return installRoot;
1095
+ if (await installRootDeclaresPackage(installRoot, "@firstpick/pi-package-webui") || await installRootContainsPackage(installRoot, "@firstpick/pi-package-webui")) return installRoot;
947
1096
 
948
1097
  const agentNpmRoot = configuredAgentNpmRoot();
949
- if (installRoot !== agentNpmRoot && await installRootDeclaresPackage(agentNpmRoot, "@firstpick/pi-package-webui")) return agentNpmRoot;
1098
+ if (installRoot !== agentNpmRoot && (await installRootDeclaresPackage(agentNpmRoot, "@firstpick/pi-package-webui") || await installRootContainsPackage(agentNpmRoot, "@firstpick/pi-package-webui"))) return agentNpmRoot;
950
1099
 
951
1100
  if (webuiDevServer) return installRoot;
952
1101
 
@@ -956,13 +1105,102 @@ async function optionalDependencyInstallRoot() {
956
1105
  );
957
1106
  }
958
1107
 
1108
+ function minimumPackageVersionFromSpec(spec) {
1109
+ const match = String(spec || "").match(/\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?/);
1110
+ return match?.[0] || "";
1111
+ }
1112
+
1113
+ function packageVersionBelowSpec(currentVersion, spec) {
1114
+ const minimum = minimumPackageVersionFromSpec(spec);
1115
+ return !!(currentVersion && minimum && isNewerPackageVersion(minimum, currentVersion));
1116
+ }
1117
+
959
1118
  function formatCommandForDisplay(command, args) {
960
1119
  return [command, ...args].map((part) => (/\s/.test(part) ? JSON.stringify(part) : part)).join(" ");
961
1120
  }
962
1121
 
963
- async function installOptionalFeaturePackage(featureId) {
1122
+ let optionalPackageNodeModulesRootsCache = null;
1123
+ async function optionalPackageNodeModulesRoots() {
1124
+ if (optionalPackageNodeModulesRootsCache) return optionalPackageNodeModulesRootsCache;
1125
+ const roots = [];
1126
+ const seen = new Set();
1127
+ const add = (root) => {
1128
+ if (!root) return;
1129
+ const normalized = path.resolve(root);
1130
+ if (seen.has(normalized)) return;
1131
+ seen.add(normalized);
1132
+ roots.push(normalized);
1133
+ };
1134
+ const configuredRoot = process.env[OPTIONAL_FEATURE_INSTALL_ROOT_ENV];
1135
+ if (configuredRoot) add(path.join(path.resolve(expandUserPath(configuredRoot)), "node_modules"));
1136
+ add(path.join(packageRoot, "node_modules"));
1137
+ add(path.join(nodeModulesParentForPackageRoot(packageRoot), "node_modules"));
1138
+ add(path.join(configuredAgentNpmRoot(), "node_modules"));
1139
+ const npmGlobalRoot = await npmGlobalNodeModulesRoot();
1140
+ if (npmGlobalRoot) add(npmGlobalRoot);
1141
+ for (const bunRoot of await bunGlobalNodeModulesRoots()) add(bunRoot);
1142
+ optionalPackageNodeModulesRootsCache = roots;
1143
+ return roots;
1144
+ }
1145
+
1146
+ async function optionalPackageCandidateRoots(packageName) {
1147
+ return (await optionalPackageNodeModulesRoots()).map((root) => packageNodeModulesPath(root, packageName));
1148
+ }
1149
+
1150
+ async function resolveInstalledPackageRoot(packageName) {
1151
+ const workspaceRoot = await workspacePackageRootForName(packageName);
1152
+ if (workspaceRoot) return workspaceRoot;
1153
+ for (const candidate of await optionalPackageCandidateRoots(packageName)) {
1154
+ if (await directoryExists(candidate)) return candidate;
1155
+ }
1156
+ return null;
1157
+ }
1158
+
1159
+ async function resolveInstalledPackageSubpath(packageName, subpath = "") {
1160
+ const root = await resolveInstalledPackageRoot(packageName);
1161
+ if (!root) return null;
1162
+ const candidate = path.join(root, subpath || "");
1163
+ try {
1164
+ await access(candidate);
1165
+ return candidate;
1166
+ } catch {
1167
+ return null;
1168
+ }
1169
+ }
1170
+
1171
+ function optionalFeatureDeclaredSpec(packageName) {
1172
+ return declaredDependencySpec(packageJson, packageName) || "";
1173
+ }
1174
+
1175
+ async function optionalFeaturePackageStatus(featureId) {
964
1176
  const packageName = OPTIONAL_FEATURE_PACKAGES.get(featureId);
965
1177
  if (!packageName) throw makeHttpError(400, `Unknown optional feature: ${featureId}`);
1178
+ const declaredSpec = optionalFeatureDeclaredSpec(packageName);
1179
+ const installedRoot = await resolveInstalledPackageRoot(packageName);
1180
+ const manifest = installedRoot ? await readJsonFileIfExists(path.join(installedRoot, "package.json")) : null;
1181
+ const installedVersion = typeof manifest?.version === "string" ? manifest.version : "";
1182
+ const updateAvailable = !!(installedVersion && packageVersionBelowSpec(installedVersion, declaredSpec));
1183
+ return {
1184
+ featureId,
1185
+ packageName,
1186
+ declaredSpec,
1187
+ installed: !!installedRoot,
1188
+ installedVersion,
1189
+ installedRoot,
1190
+ updateAvailable,
1191
+ updateReason: updateAvailable ? `installed ${installedVersion} is older than Web UI expects (${declaredSpec})` : "",
1192
+ };
1193
+ }
1194
+
1195
+ async function optionalFeaturePackageStatuses() {
1196
+ const features = [];
1197
+ for (const featureId of OPTIONAL_FEATURE_PACKAGES.keys()) features.push(await optionalFeaturePackageStatus(featureId));
1198
+ return { features };
1199
+ }
1200
+
1201
+ async function installOptionalFeaturePackage(featureId) {
1202
+ const beforeStatus = await optionalFeaturePackageStatus(featureId);
1203
+ const packageName = beforeStatus.packageName;
966
1204
 
967
1205
  const installRoot = await optionalDependencyInstallRoot();
968
1206
  const npmCommand = process.env.PI_WEBUI_NPM_BIN || "npm";
@@ -978,6 +1216,8 @@ async function installOptionalFeaturePackage(featureId) {
978
1216
  const details = [result.error, result.timedOut ? "timed out" : undefined, result.stderr?.trim(), result.stdout?.trim()].filter(Boolean).join("\n");
979
1217
  throw makeHttpError(500, `Optional feature install failed: ${command}${details ? `\n${details}` : ""}`);
980
1218
  }
1219
+ const afterStatus = await optionalFeaturePackageStatus(featureId);
1220
+ const operation = beforeStatus.installed ? "Updated" : "Installed";
981
1221
  return {
982
1222
  featureId,
983
1223
  packageName,
@@ -985,7 +1225,8 @@ async function installOptionalFeaturePackage(featureId) {
985
1225
  command,
986
1226
  stdout: result.stdout,
987
1227
  stderr: result.stderr,
988
- message: `Installed optional feature package ${packageName}. Reload the active Pi tab to load new resources.`,
1228
+ status: afterStatus,
1229
+ message: `${operation} optional feature package ${packageName}${afterStatus.installedVersion ? ` to ${afterStatus.installedVersion}` : ""}. Reload the active Pi tab to load new resources.`,
989
1230
  };
990
1231
  }
991
1232
 
@@ -2955,6 +3196,58 @@ function cleanGitCommitMessageInput(value) {
2955
3196
  return message;
2956
3197
  }
2957
3198
 
3199
+ function parseGitPorcelainZEntries(text) {
3200
+ const fields = String(text || "").split("\0").filter(Boolean);
3201
+ const entries = [];
3202
+ for (let index = 0; index < fields.length; index++) {
3203
+ const field = fields[index];
3204
+ if (field.length < 4) {
3205
+ entries.push({ x: "", y: "", path: field, unsupported: true });
3206
+ continue;
3207
+ }
3208
+ const x = field[0] || " ";
3209
+ const y = field[1] || " ";
3210
+ const filePath = field.slice(3);
3211
+ const entry = { x, y, path: filePath };
3212
+ if ((x === "R" || x === "C") && index + 1 < fields.length) entry.oldPath = fields[++index];
3213
+ entries.push(entry);
3214
+ }
3215
+ return entries;
3216
+ }
3217
+
3218
+ function gitWorkflowDefaultCommitAction(entry) {
3219
+ if (!entry || entry.y !== " ") return "";
3220
+ if (entry.x === "A") return "created";
3221
+ if (entry.x === "M" || entry.x === "T") return "updated";
3222
+ if (entry.x === "D") return "deleted";
3223
+ return "";
3224
+ }
3225
+
3226
+ function formatGitWorkflowDefaultCommitPath(filePath) {
3227
+ return String(filePath || "").replace(/[\0\r\n\t]+/g, " ").replace(/\s+/g, " ").trim().slice(0, 4000);
3228
+ }
3229
+
3230
+ async function readGitWorkflowDefaultCommitMessage(cwd) {
3231
+ const root = await getGitRoot(cwd);
3232
+ const statusText = await runGitReadCommand(root, ["status", "--porcelain=v1", "-z", "--untracked-files=all"], { maxOutputLength: 120_000 });
3233
+ const entries = parseGitPorcelainZEntries(statusText);
3234
+ const empty = (reason, extra = {}) => ({ root, message: "", reason, ...extra });
3235
+ if (entries.length === 0) return empty("No changed files are ready for a default commit message.");
3236
+ if (entries.length !== 1) return empty(`Expected exactly one changed file for a default commit message; found ${entries.length}.`);
3237
+ const [entry] = entries;
3238
+ const action = gitWorkflowDefaultCommitAction(entry);
3239
+ const displayPath = formatGitWorkflowDefaultCommitPath(entry.path);
3240
+ if (!action || !displayPath) {
3241
+ return empty("The only changed file is not a staged created, updated, or deleted file.", { path: entry.path || "" });
3242
+ }
3243
+ return {
3244
+ root,
3245
+ message: `${action} ${displayPath}`,
3246
+ action,
3247
+ path: entry.path,
3248
+ };
3249
+ }
3250
+
2958
3251
  function cleanGitHubUsername(value) {
2959
3252
  const username = String(value || "").trim().replace(/^@+/, "");
2960
3253
  if (!username) throw new Error("GitHub username is required");
@@ -3300,6 +3593,8 @@ async function handleGitWorkflowRequest(pathname, body = {}, cwd = options.cwd)
3300
3593
  switch (pathname) {
3301
3594
  case "/api/git-workflow/message":
3302
3595
  return { ok: true, data: await readGitWorkflowMessages(cwd) };
3596
+ case "/api/git-workflow/default-commit-message":
3597
+ return { ok: true, data: await readGitWorkflowDefaultCommitMessage(cwd) };
3303
3598
  case "/api/git-workflow/branch-name":
3304
3599
  return { ok: true, data: await readGitWorkflowBranchName(cwd) };
3305
3600
  case "/api/git-workflow/pr-description":
@@ -3917,16 +4212,8 @@ function parseNodeModulesPackageRef(manifestEntry) {
3917
4212
  async function resolveStartedWebuiManifestResource(manifestEntry) {
3918
4213
  const nodeModulesRef = parseNodeModulesPackageRef(manifestEntry);
3919
4214
  if (nodeModulesRef && WEBUI_CONTROLLED_PACKAGES.has(nodeModulesRef.packageName)) {
3920
- const workspaceRoot = await workspacePackageRootForName(nodeModulesRef.packageName);
3921
- if (workspaceRoot) {
3922
- const devCandidate = path.join(workspaceRoot, nodeModulesRef.subpath);
3923
- try {
3924
- await access(devCandidate);
3925
- return devCandidate;
3926
- } catch {
3927
- // Fall back to the started package's node_modules copy below.
3928
- }
3929
- }
4215
+ const installedCandidate = await resolveInstalledPackageSubpath(nodeModulesRef.packageName, nodeModulesRef.subpath);
4216
+ if (installedCandidate) return installedCandidate;
3930
4217
  }
3931
4218
 
3932
4219
  const candidate = path.resolve(packageRoot, manifestEntry);
@@ -6324,6 +6611,11 @@ const initialTab = initialTabs[0];
6324
6611
  let currentHost = options.host;
6325
6612
  let networkRebindInProgress = false;
6326
6613
  let networkRebindTargetHost = null;
6614
+ const remoteAuth = {
6615
+ pin: undefined,
6616
+ token: undefined,
6617
+ tokenExpiresAt: 0,
6618
+ };
6327
6619
 
6328
6620
  function localNetworkAddresses() {
6329
6621
  const addresses = [];
@@ -6336,7 +6628,39 @@ function localNetworkAddresses() {
6336
6628
  return [...new Set(addresses)].sort();
6337
6629
  }
6338
6630
 
6339
- 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 } = {}) {
6340
6664
  const open = !isLocalHost(currentHost);
6341
6665
  const targetHost = networkRebindTargetHost || currentHost;
6342
6666
  const opening = networkRebindInProgress && !isLocalHost(targetHost);
@@ -6350,6 +6674,7 @@ function networkStatus() {
6350
6674
  port: options.port,
6351
6675
  localUrl: `http://127.0.0.1:${options.port}/`,
6352
6676
  networkUrls,
6677
+ auth: remoteAuthStatus({ includePin: includeAuthPin }),
6353
6678
  };
6354
6679
  }
6355
6680
 
@@ -6373,6 +6698,23 @@ function closeSseClientsForRebind(nextHost) {
6373
6698
  }
6374
6699
  }
6375
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
+
6376
6718
  function closeServerListener() {
6377
6719
  return new Promise((resolve, reject) => {
6378
6720
  if (!server.listening) {
@@ -6416,7 +6758,7 @@ function listenOn(host) {
6416
6758
 
6417
6759
  async function openToLocalNetwork() {
6418
6760
  const nextHost = "0.0.0.0";
6419
- if (!isLocalHost(currentHost) || networkRebindInProgress) return networkStatus();
6761
+ if (!isLocalHost(currentHost) || networkRebindInProgress) return networkStatus({ includeAuthPin: true });
6420
6762
 
6421
6763
  networkRebindInProgress = true;
6422
6764
  networkRebindTargetHost = nextHost;
@@ -6426,8 +6768,8 @@ async function openToLocalNetwork() {
6426
6768
  await closeServerListener();
6427
6769
  await listenOn(nextHost);
6428
6770
  currentHost = nextHost;
6429
- console.warn("WARNING: Web UI is now reachable from the local network and has no authentication.");
6430
- 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 });
6431
6773
  } catch (error) {
6432
6774
  console.error("Failed to open Web UI to local network:", sanitizeError(error));
6433
6775
  if (!server.listening) {
@@ -6456,6 +6798,7 @@ async function closeNetworkAccess() {
6456
6798
  await closeServerListener();
6457
6799
  await listenOn(nextHost);
6458
6800
  currentHost = nextHost;
6801
+ resetRemoteAuth();
6459
6802
  console.warn("Web UI network access closed; listening on localhost only.");
6460
6803
  return networkStatus();
6461
6804
  } catch (error) {
@@ -6474,6 +6817,8 @@ async function closeNetworkAccess() {
6474
6817
  }
6475
6818
  }
6476
6819
 
6820
+ if (!isLocalHost(currentHost) && options.remoteAuth !== false) enableRemoteAuth("startup network listener");
6821
+
6477
6822
  async function safeRpcData(tab, command, timeoutMs = STATUS_RPC_TIMEOUT_MS) {
6478
6823
  try {
6479
6824
  const response = await tab.rpc.send(command, timeoutMs);
@@ -6551,9 +6896,9 @@ async function tabStatusDetails(tab) {
6551
6896
  };
6552
6897
  }
6553
6898
 
6554
- async function webuiStatus({ detailed = false, eventLimit = 40 } = {}) {
6899
+ async function webuiStatus({ detailed = false, eventLimit = 40, includeAuthPin = false } = {}) {
6555
6900
  const tab = firstTab();
6556
- const network = networkStatus();
6901
+ const network = networkStatus({ includeAuthPin });
6557
6902
  const statusTabs = listTabs();
6558
6903
  const data = {
6559
6904
  online: true,
@@ -6589,6 +6934,42 @@ const server = createServer(async (req, res) => {
6589
6934
  try {
6590
6935
  const url = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`);
6591
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
+
6592
6973
  if (url.pathname === "/api/tabs" && req.method === "GET") {
6593
6974
  sendJson(res, 200, { ok: true, data: { tabs: await listTabsWithReconciledActivity() } });
6594
6975
  return;
@@ -6658,7 +7039,7 @@ const server = createServer(async (req, res) => {
6658
7039
  }
6659
7040
 
6660
7041
  if (url.pathname === "/api/health" && req.method === "GET") {
6661
- const status = await webuiStatus();
7042
+ const status = await webuiStatus({ includeAuthPin: isLocalRequest(req) });
6662
7043
  sendJson(res, 200, {
6663
7044
  ok: true,
6664
7045
  webuiVersion: status.webuiVersion,
@@ -6679,7 +7060,7 @@ const server = createServer(async (req, res) => {
6679
7060
  const detailed = ["1", "true", "yes", "detailed"].includes(String(url.searchParams.get("detailed") || "").toLowerCase());
6680
7061
  const parsedEventLimit = Number.parseInt(url.searchParams.get("events") || "40", 10);
6681
7062
  const eventLimit = Number.isFinite(parsedEventLimit) ? parsedEventLimit : 40;
6682
- sendJson(res, 200, { ok: true, data: await webuiStatus({ detailed, eventLimit }) });
7063
+ sendJson(res, 200, { ok: true, data: await webuiStatus({ detailed, eventLimit, includeAuthPin: isLocalRequest(req) }) });
6683
7064
  return;
6684
7065
  }
6685
7066
 
@@ -6716,13 +7097,13 @@ const server = createServer(async (req, res) => {
6716
7097
  }
6717
7098
 
6718
7099
  if (url.pathname === "/api/network" && req.method === "GET") {
6719
- sendJson(res, 200, { ok: true, data: networkStatus() });
7100
+ sendJson(res, 200, { ok: true, data: networkStatus({ includeAuthPin: isLocalRequest(req) }) });
6720
7101
  return;
6721
7102
  }
6722
7103
 
6723
7104
  if (url.pathname === "/api/network/open" && req.method === "POST") {
6724
7105
  requireLocalhostRoute(req, url.pathname);
6725
- const before = networkStatus();
7106
+ const before = networkStatus({ includeAuthPin: true });
6726
7107
  const shouldOpen = !before.open && !networkRebindInProgress;
6727
7108
  sendJson(res, 202, { ok: true, data: { ...before, opening: shouldOpen || before.opening, closing: before.closing } }, { connection: "close" });
6728
7109
  if (shouldOpen) {
@@ -6733,7 +7114,7 @@ const server = createServer(async (req, res) => {
6733
7114
 
6734
7115
  if (url.pathname === "/api/network/close" && req.method === "POST") {
6735
7116
  requireLocalhostRoute(req, url.pathname);
6736
- const before = networkStatus();
7117
+ const before = networkStatus({ includeAuthPin: true });
6737
7118
  const shouldClose = before.open && !networkRebindInProgress;
6738
7119
  sendJson(res, 202, { ok: true, data: { ...before, opening: before.opening, closing: shouldClose || before.closing } }, { connection: "close" });
6739
7120
  if (shouldClose) {
@@ -6964,6 +7345,11 @@ const server = createServer(async (req, res) => {
6964
7345
  return;
6965
7346
  }
6966
7347
 
7348
+ if (url.pathname === "/api/optional-features" && req.method === "GET") {
7349
+ sendJson(res, 200, { ok: true, data: await optionalFeaturePackageStatuses() });
7350
+ return;
7351
+ }
7352
+
6967
7353
  if (url.pathname === "/api/optional-feature-install" && req.method === "POST") {
6968
7354
  requireLocalhostRoute(req, url.pathname);
6969
7355
  const body = await readJsonBody(req);
@@ -7216,7 +7602,7 @@ server.listen(options.port, currentHost, () => {
7216
7602
  else console.log("Pi RPC: waiting for CWD selection in the Web UI");
7217
7603
  if (restoreTabs.length) console.log(`Restored Web UI tabs: ${initialTabs.length}`);
7218
7604
  if (!isLocalHost(currentHost)) {
7219
- 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.`);
7220
7606
  }
7221
7607
  });
7222
7608