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