@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 +16 -4
- package/bin/pi-webui.mjs +254 -15
- package/index.ts +16 -1
- package/lib/trust-boundaries.mjs +1 -0
- package/package.json +1 -1
- package/public/app.js +51 -2
- package/public/index.html +7 -0
- package/tests/http-endpoints-harness.test.mjs +55 -0
- package/tests/mobile-static.test.mjs +2 -0
- package/tests/session-auth-harness.test.mjs +4 -0
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
|
|
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
|
|
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
|
-
-
|
|
201
|
-
-
|
|
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
|
|
266
|
-
|
|
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
|
|
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(
|
|
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(
|
|
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
|
}
|
package/lib/trust-boundaries.mjs
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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(
|
|
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 });
|