@firstpick/pi-package-webui 0.3.7 → 0.3.9
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 +2 -1
- package/WEBUI_TUI_NATIVE_PARITY.json +22 -22
- package/bin/pi-webui.mjs +259 -112
- package/images/WebUI_v0.3.7.png +0 -0
- package/index.ts +15 -4
- package/lib/auth-actions.mjs +81 -0
- package/lib/native-command-adapter.mjs +220 -0
- package/lib/session-actions.mjs +134 -0
- package/lib/temp-artifacts.mjs +34 -0
- package/lib/trust-boundaries.mjs +141 -0
- package/package.json +8 -4
- package/public/app.js +554 -94
- package/public/index.html +2 -2
- package/public/service-worker.js +23 -9
- package/public/styles.css +111 -0
- package/start-webui.sh +6 -5
- package/tests/fixtures/fake-pi.mjs +73 -0
- package/tests/http-endpoints-harness.test.mjs +146 -0
- package/tests/mobile-static.test.mjs +45 -21
- package/tests/native-parity-harness.test.mjs +147 -0
- package/tests/native-parity.test.mjs +25 -6
- package/tests/run-all.mjs +19 -0
- package/tests/session-auth-harness.test.mjs +140 -0
- package/tests/temp-artifacts-harness.test.mjs +38 -0
package/public/index.html
CHANGED
|
@@ -144,8 +144,8 @@
|
|
|
144
144
|
type="button"
|
|
145
145
|
hidden
|
|
146
146
|
title="Guided Git workflow"
|
|
147
|
-
aria-label="Start guided Git workflow:
|
|
148
|
-
data-tooltip="
|
|
147
|
+
aria-label="Start guided Git workflow: stage all changes, generate and preview staged commit messages, optionally create a PR branch, commit short or long, then push or create a pull request. Cancel is available at each step."
|
|
148
|
+
data-tooltip="Guided Git workflow: 1. Stage all changes with git add . 2. Generate and preview staged commit messages. 3. Optional: create or type a PR branch before committing. 4. Commit with the short or long message. 5. Push normally, or push the PR branch, generate/review /pr, and create the PR. Cancel is available at each step."
|
|
149
149
|
><svg class="composer-icon composer-icon-github" viewBox="0 0 16 16" aria-hidden="true" focusable="false"><path fill="currentColor" d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27s1.36.09 2 .27c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0 0 16 8c0-4.42-3.58-8-8-8Z"/></svg></button>
|
|
150
150
|
<div class="composer-publish-menu" hidden>
|
|
151
151
|
<button
|
package/public/service-worker.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
const CACHE_NAME = "pi-webui-pwa-
|
|
1
|
+
const CACHE_NAME = "pi-webui-pwa-v26";
|
|
2
2
|
const APP_SHELL = [
|
|
3
3
|
"/",
|
|
4
4
|
"/index.html",
|
|
@@ -37,6 +37,26 @@ self.addEventListener("notificationclick", (event) => {
|
|
|
37
37
|
);
|
|
38
38
|
});
|
|
39
39
|
|
|
40
|
+
// Network-first keeps the app shell fresh after deploys regardless of
|
|
41
|
+
// CACHE_NAME or ?v= cache-buster drift; the cache only serves offline clients.
|
|
42
|
+
function fetchThenCache(request) {
|
|
43
|
+
return fetch(request).then((response) => {
|
|
44
|
+
if (response.ok) {
|
|
45
|
+
const copy = response.clone();
|
|
46
|
+
caches.open(CACHE_NAME).then((cache) => cache.put(request, copy));
|
|
47
|
+
}
|
|
48
|
+
return response;
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ignoreSearch lets precached bare paths satisfy ?v= cache-busted requests offline.
|
|
53
|
+
function cachedAppShell(request, fallbackPath) {
|
|
54
|
+
return caches.match(request, { ignoreSearch: true }).then((cached) => {
|
|
55
|
+
if (cached || !fallbackPath) return cached;
|
|
56
|
+
return caches.match(fallbackPath, { ignoreSearch: true });
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
40
60
|
self.addEventListener("fetch", (event) => {
|
|
41
61
|
const { request } = event;
|
|
42
62
|
if (request.method !== "GET") return;
|
|
@@ -45,16 +65,10 @@ self.addEventListener("fetch", (event) => {
|
|
|
45
65
|
if (url.origin !== self.location.origin || url.pathname.startsWith("/api/")) return;
|
|
46
66
|
|
|
47
67
|
if (request.mode === "navigate") {
|
|
48
|
-
event.respondWith(
|
|
68
|
+
event.respondWith(fetchThenCache(request).catch(() => cachedAppShell(request, "/index.html")));
|
|
49
69
|
return;
|
|
50
70
|
}
|
|
51
71
|
|
|
52
72
|
if (!APP_SHELL.includes(url.pathname)) return;
|
|
53
|
-
event.respondWith(
|
|
54
|
-
caches.match(request).then((cached) => cached || fetch(request).then((response) => {
|
|
55
|
-
const copy = response.clone();
|
|
56
|
-
caches.open(CACHE_NAME).then((cache) => cache.put(request, copy));
|
|
57
|
-
return response;
|
|
58
|
-
})),
|
|
59
|
-
);
|
|
73
|
+
event.respondWith(fetchThenCache(request).catch(() => cachedAppShell(request)));
|
|
60
74
|
});
|
package/public/styles.css
CHANGED
|
@@ -1878,6 +1878,7 @@ button.footer-tui-item {
|
|
|
1878
1878
|
padding: 0.28rem 0.52rem;
|
|
1879
1879
|
border-radius: 0.7rem;
|
|
1880
1880
|
}
|
|
1881
|
+
button.footer-metric,
|
|
1881
1882
|
button.footer-meta {
|
|
1882
1883
|
appearance: none;
|
|
1883
1884
|
color: inherit;
|
|
@@ -1885,18 +1886,26 @@ button.footer-meta {
|
|
|
1885
1886
|
text-align: left;
|
|
1886
1887
|
cursor: pointer;
|
|
1887
1888
|
}
|
|
1889
|
+
.footer-metric-action,
|
|
1888
1890
|
.footer-meta-action {
|
|
1889
1891
|
position: relative;
|
|
1890
1892
|
border-color: rgba(148, 226, 213, 0.26);
|
|
1891
1893
|
box-shadow: inset 0 1px 0 rgba(255,255,255,0.045), inset 0 0 0 1px rgba(148, 226, 213, 0.055), 0 0.45rem 1rem rgba(0, 0, 0, 0.10);
|
|
1892
1894
|
transition: border-color 140ms ease, box-shadow 140ms ease, transform 140ms ease;
|
|
1893
1895
|
}
|
|
1896
|
+
.footer-metric-action:hover,
|
|
1897
|
+
.footer-metric-action:focus-visible,
|
|
1894
1898
|
.footer-meta-action:hover,
|
|
1895
1899
|
.footer-meta-action:focus-visible {
|
|
1896
1900
|
border-color: rgba(166, 227, 161, 0.46);
|
|
1897
1901
|
box-shadow: inset 0 1px 0 rgba(255,255,255,0.055), 0 0 1rem rgba(166, 227, 161, 0.16);
|
|
1898
1902
|
outline: none;
|
|
1899
1903
|
}
|
|
1904
|
+
.footer-metric-action[aria-busy="true"],
|
|
1905
|
+
.footer-meta-action[aria-busy="true"] {
|
|
1906
|
+
cursor: progress;
|
|
1907
|
+
opacity: 0.9;
|
|
1908
|
+
}
|
|
1900
1909
|
.footer-workspace.footer-meta-action .footer-meta-value {
|
|
1901
1910
|
color: var(--ctp-green);
|
|
1902
1911
|
}
|
|
@@ -2066,6 +2075,32 @@ button.footer-meta {
|
|
|
2066
2075
|
border-color: rgba(249, 226, 175, 0.36);
|
|
2067
2076
|
background: transparent;
|
|
2068
2077
|
}
|
|
2078
|
+
.footer-changes-with-files {
|
|
2079
|
+
position: relative;
|
|
2080
|
+
cursor: default;
|
|
2081
|
+
}
|
|
2082
|
+
.footer-changes-with-files:hover,
|
|
2083
|
+
.footer-changes-with-files:focus,
|
|
2084
|
+
.footer-changes-with-files:focus-within {
|
|
2085
|
+
z-index: 50;
|
|
2086
|
+
}
|
|
2087
|
+
.footer-changes-with-files:focus-visible {
|
|
2088
|
+
outline: 1px solid rgba(249, 226, 175, 0.48);
|
|
2089
|
+
outline-offset: 2px;
|
|
2090
|
+
}
|
|
2091
|
+
.footer-changes-with-files::after {
|
|
2092
|
+
content: "";
|
|
2093
|
+
position: absolute;
|
|
2094
|
+
left: 0;
|
|
2095
|
+
right: 0;
|
|
2096
|
+
bottom: 100%;
|
|
2097
|
+
display: none;
|
|
2098
|
+
height: 0.62rem;
|
|
2099
|
+
}
|
|
2100
|
+
.footer-changes-with-files:hover::after,
|
|
2101
|
+
.footer-changes-with-files:focus-within::after {
|
|
2102
|
+
display: block;
|
|
2103
|
+
}
|
|
2069
2104
|
.footer-changes .footer-meta-label {
|
|
2070
2105
|
color: rgba(249, 226, 175, 0.88);
|
|
2071
2106
|
text-shadow: 0 0 0.52rem rgba(249, 226, 175, 0.20);
|
|
@@ -2076,6 +2111,82 @@ button.footer-meta {
|
|
|
2076
2111
|
letter-spacing: 0.01em;
|
|
2077
2112
|
text-shadow: 0 0 0.72rem rgba(249, 226, 175, 0.28);
|
|
2078
2113
|
}
|
|
2114
|
+
.footer-changed-files-popover {
|
|
2115
|
+
position: absolute;
|
|
2116
|
+
left: 0;
|
|
2117
|
+
bottom: calc(100% + 0.48rem);
|
|
2118
|
+
z-index: 60;
|
|
2119
|
+
display: none;
|
|
2120
|
+
gap: 0.44rem;
|
|
2121
|
+
width: min(32rem, calc(100vw - 2rem));
|
|
2122
|
+
max-height: min(42dvh, 28rem);
|
|
2123
|
+
overflow: auto;
|
|
2124
|
+
padding: 0.68rem;
|
|
2125
|
+
border: 1px solid rgba(249, 226, 175, 0.32);
|
|
2126
|
+
border-radius: 0.85rem;
|
|
2127
|
+
background:
|
|
2128
|
+
radial-gradient(circle at 8% 0%, rgba(249, 226, 175, 0.13), transparent 12rem),
|
|
2129
|
+
linear-gradient(145deg, rgba(var(--ctp-crust-rgb), 0.98), rgba(var(--ctp-base-rgb), 0.96));
|
|
2130
|
+
box-shadow: 0 1rem 2.4rem rgba(var(--ctp-crust-rgb), 0.64), 0 0 1.2rem rgba(249, 226, 175, 0.12), inset 0 1px 0 rgba(255,255,255,0.055);
|
|
2131
|
+
}
|
|
2132
|
+
.footer-changes-with-files:hover .footer-changed-files-popover,
|
|
2133
|
+
.footer-changes-with-files:focus .footer-changed-files-popover,
|
|
2134
|
+
.footer-changes-with-files:focus-within .footer-changed-files-popover {
|
|
2135
|
+
display: grid;
|
|
2136
|
+
}
|
|
2137
|
+
.footer-changed-files-title,
|
|
2138
|
+
.footer-changed-files-heading {
|
|
2139
|
+
color: var(--ctp-yellow);
|
|
2140
|
+
font-size: 0.7rem;
|
|
2141
|
+
font-weight: 950;
|
|
2142
|
+
letter-spacing: 0.12em;
|
|
2143
|
+
text-transform: uppercase;
|
|
2144
|
+
}
|
|
2145
|
+
.footer-changed-files-group {
|
|
2146
|
+
display: grid;
|
|
2147
|
+
gap: 0.28rem;
|
|
2148
|
+
}
|
|
2149
|
+
.footer-changed-files-list {
|
|
2150
|
+
display: grid;
|
|
2151
|
+
gap: 0.22rem;
|
|
2152
|
+
}
|
|
2153
|
+
.footer-changed-file {
|
|
2154
|
+
display: grid;
|
|
2155
|
+
grid-template-columns: auto minmax(0, 1fr);
|
|
2156
|
+
align-items: center;
|
|
2157
|
+
gap: 0.42rem;
|
|
2158
|
+
width: 100%;
|
|
2159
|
+
padding: 0.34rem 0.45rem;
|
|
2160
|
+
border: 1px solid rgba(180, 190, 254, 0.15);
|
|
2161
|
+
border-radius: 0.52rem;
|
|
2162
|
+
color: rgba(var(--ctp-text-rgb), 0.92);
|
|
2163
|
+
background: rgba(var(--ctp-crust-rgb), 0.42);
|
|
2164
|
+
font: inherit;
|
|
2165
|
+
text-align: left;
|
|
2166
|
+
cursor: pointer;
|
|
2167
|
+
}
|
|
2168
|
+
.footer-changed-file:hover,
|
|
2169
|
+
.footer-changed-file:focus-visible {
|
|
2170
|
+
border-color: rgba(249, 226, 175, 0.44);
|
|
2171
|
+
background: rgba(249, 226, 175, 0.10);
|
|
2172
|
+
outline: none;
|
|
2173
|
+
}
|
|
2174
|
+
.footer-changed-file-status {
|
|
2175
|
+
color: rgba(var(--ctp-subtext-rgb), 0.72);
|
|
2176
|
+
font-weight: 950;
|
|
2177
|
+
}
|
|
2178
|
+
.footer-changed-file-path {
|
|
2179
|
+
min-width: 0;
|
|
2180
|
+
overflow: hidden;
|
|
2181
|
+
color: rgba(var(--ctp-text-rgb), 0.94);
|
|
2182
|
+
font-weight: 760;
|
|
2183
|
+
text-overflow: ellipsis;
|
|
2184
|
+
white-space: nowrap;
|
|
2185
|
+
}
|
|
2186
|
+
.footer-changed-file.modified .footer-changed-file-status { color: var(--ctp-yellow); }
|
|
2187
|
+
.footer-changed-file.staged .footer-changed-file-status { color: var(--ctp-green); }
|
|
2188
|
+
.footer-changed-file.untracked .footer-changed-file-status { color: var(--ctp-blue); }
|
|
2189
|
+
.footer-changed-file.conflicted .footer-changed-file-status { color: var(--ctp-red); }
|
|
2079
2190
|
.footer-git-extra {
|
|
2080
2191
|
border-color: rgba(137, 180, 250, 0.34);
|
|
2081
2192
|
background: transparent;
|
package/start-webui.sh
CHANGED
|
@@ -196,6 +196,8 @@ http_ok() {
|
|
|
196
196
|
curl -fsS --max-time 2 "$url" >/dev/null 2>&1
|
|
197
197
|
elif command -v wget >/dev/null 2>&1; then
|
|
198
198
|
wget -q --timeout=2 --tries=1 --spider "$url" >/dev/null 2>&1
|
|
199
|
+
elif command -v node >/dev/null 2>&1; then
|
|
200
|
+
node -e 'fetch(process.argv[1], { signal: AbortSignal.timeout(2000) }).then((r) => process.exit(r.ok ? 0 : 1), () => process.exit(1));' "$url" >/dev/null 2>&1
|
|
199
201
|
else
|
|
200
202
|
return 1
|
|
201
203
|
fi
|
|
@@ -214,6 +216,8 @@ http_get() {
|
|
|
214
216
|
curl -fsS --max-time 5 "$url"
|
|
215
217
|
elif command -v wget >/dev/null 2>&1; then
|
|
216
218
|
wget -q --timeout=5 --tries=1 -O - "$url"
|
|
219
|
+
elif command -v node >/dev/null 2>&1; then
|
|
220
|
+
node -e 'fetch(process.argv[1], { signal: AbortSignal.timeout(5000) }).then(async (r) => { if (!r.ok) process.exit(1); process.stdout.write(await r.text()); }, () => process.exit(1));' "$url"
|
|
217
221
|
else
|
|
218
222
|
return 1
|
|
219
223
|
fi
|
|
@@ -225,6 +229,8 @@ http_post_json() {
|
|
|
225
229
|
|
|
226
230
|
if command -v curl >/dev/null 2>&1; then
|
|
227
231
|
curl -fsS --max-time 10 -X POST "$url" -H "Content-Type: application/json" --data "$body"
|
|
232
|
+
elif command -v node >/dev/null 2>&1; then
|
|
233
|
+
node -e 'fetch(process.argv[1], { method: "POST", headers: { "Content-Type": "application/json" }, body: process.argv[2], signal: AbortSignal.timeout(10000) }).then(async (r) => { if (!r.ok) process.exit(1); process.stdout.write(await r.text()); }, () => process.exit(1));' "$url" "$body"
|
|
228
234
|
else
|
|
229
235
|
return 1
|
|
230
236
|
fi
|
|
@@ -336,11 +342,6 @@ wait_until_ready() {
|
|
|
336
342
|
local url="$1"
|
|
337
343
|
local pid="$2"
|
|
338
344
|
|
|
339
|
-
if ! command -v curl >/dev/null 2>&1 && ! command -v wget >/dev/null 2>&1; then
|
|
340
|
-
sleep 1
|
|
341
|
-
return 0
|
|
342
|
-
fi
|
|
343
|
-
|
|
344
345
|
for _ in {1..50}; do
|
|
345
346
|
if ! kill -0 "$pid" 2>/dev/null; then
|
|
346
347
|
return 2
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Minimal JSONL RPC stub standing in for the pi coding agent so HTTP endpoint
|
|
3
|
+
// tests can boot the real pi-webui server without a model provider.
|
|
4
|
+
import { createInterface } from "node:readline";
|
|
5
|
+
|
|
6
|
+
const sessionIndex = process.argv.indexOf("--session");
|
|
7
|
+
const sessionFile = sessionIndex !== -1 ? process.argv[sessionIndex + 1] : undefined;
|
|
8
|
+
|
|
9
|
+
let activeBash = 0;
|
|
10
|
+
let peakBash = 0;
|
|
11
|
+
|
|
12
|
+
function respond(payload) {
|
|
13
|
+
process.stdout.write(`${JSON.stringify(payload)}\n`);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const rl = createInterface({ input: process.stdin });
|
|
17
|
+
rl.on("line", (line) => {
|
|
18
|
+
if (!line.trim()) return;
|
|
19
|
+
let command;
|
|
20
|
+
try {
|
|
21
|
+
command = JSON.parse(line);
|
|
22
|
+
} catch {
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
const { id, type } = command || {};
|
|
26
|
+
if (!id || !type) return;
|
|
27
|
+
const base = { type: "response", id, command: type, success: true };
|
|
28
|
+
|
|
29
|
+
switch (type) {
|
|
30
|
+
case "get_state":
|
|
31
|
+
respond({
|
|
32
|
+
...base,
|
|
33
|
+
data: {
|
|
34
|
+
model: { provider: "fake", id: "fake-model" },
|
|
35
|
+
thinkingLevel: "off",
|
|
36
|
+
isStreaming: false,
|
|
37
|
+
isCompacting: false,
|
|
38
|
+
steeringMode: "one-at-a-time",
|
|
39
|
+
followUpMode: "one-at-a-time",
|
|
40
|
+
sessionFile,
|
|
41
|
+
sessionId: "fake-session",
|
|
42
|
+
sessionName: "fake",
|
|
43
|
+
autoCompactionEnabled: false,
|
|
44
|
+
messageCount: 0,
|
|
45
|
+
pendingMessageCount: 0,
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
return;
|
|
49
|
+
case "get_messages":
|
|
50
|
+
respond({ ...base, data: { messages: [] } });
|
|
51
|
+
return;
|
|
52
|
+
case "get_available_models":
|
|
53
|
+
respond({ ...base, data: { models: [{ provider: "fake", id: "fake-model", name: "Fake Model" }] } });
|
|
54
|
+
return;
|
|
55
|
+
case "get_session_stats":
|
|
56
|
+
respond({ ...base, data: { tokens: 0 } });
|
|
57
|
+
return;
|
|
58
|
+
case "get_last_assistant_text":
|
|
59
|
+
respond({ ...base, data: { text: "fake last text" } });
|
|
60
|
+
return;
|
|
61
|
+
case "bash": {
|
|
62
|
+
activeBash += 1;
|
|
63
|
+
peakBash = Math.max(peakBash, activeBash);
|
|
64
|
+
setTimeout(() => {
|
|
65
|
+
activeBash -= 1;
|
|
66
|
+
respond({ ...base, data: { output: `peak:${peakBash}`, exitCode: 0, cancelled: false } });
|
|
67
|
+
}, 150);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
default:
|
|
71
|
+
respond({ ...base, data: {} });
|
|
72
|
+
}
|
|
73
|
+
});
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { chmod, mkdtemp, rm } from "node:fs/promises";
|
|
4
|
+
import { networkInterfaces, tmpdir } from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
7
|
+
import { dirname, join } from "node:path";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
|
|
10
|
+
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
11
|
+
const serverScript = join(root, "bin", "pi-webui.mjs");
|
|
12
|
+
const fakePi = join(root, "tests", "fixtures", "fake-pi.mjs");
|
|
13
|
+
const port = 30000 + Math.floor(Math.random() * 20000);
|
|
14
|
+
|
|
15
|
+
function lanAddress() {
|
|
16
|
+
for (const entries of Object.values(networkInterfaces())) {
|
|
17
|
+
for (const entry of entries || []) {
|
|
18
|
+
if (entry.family === "IPv4" && !entry.internal) return entry.address;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function request(host, pathname, { method = "GET", body, timeoutMs = 5_000 } = {}) {
|
|
25
|
+
const response = await fetch(`http://${host}:${port}${pathname}`, {
|
|
26
|
+
method,
|
|
27
|
+
headers: body === undefined ? undefined : { "Content-Type": "application/json" },
|
|
28
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
29
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
30
|
+
});
|
|
31
|
+
let payload;
|
|
32
|
+
try {
|
|
33
|
+
payload = await response.json();
|
|
34
|
+
} catch {
|
|
35
|
+
payload = undefined;
|
|
36
|
+
}
|
|
37
|
+
return { status: response.status, body: payload };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const cwd = await mkdtemp(path.join(tmpdir(), "pi-webui-http-harness-"));
|
|
41
|
+
await chmod(fakePi, 0o755);
|
|
42
|
+
|
|
43
|
+
const child = spawn(process.execPath, [serverScript, "--cwd", cwd, "--host", "0.0.0.0", "--port", String(port), "--pi", fakePi], {
|
|
44
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
45
|
+
});
|
|
46
|
+
let serverOutput = "";
|
|
47
|
+
child.stdout.on("data", (chunk) => {
|
|
48
|
+
serverOutput += String(chunk);
|
|
49
|
+
});
|
|
50
|
+
child.stderr.on("data", (chunk) => {
|
|
51
|
+
serverOutput += String(chunk);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
// Wait for the HTTP server to accept requests.
|
|
56
|
+
let health;
|
|
57
|
+
for (let attempt = 0; attempt < 100; attempt++) {
|
|
58
|
+
if (child.exitCode !== null) break;
|
|
59
|
+
try {
|
|
60
|
+
health = await request("127.0.0.1", "/api/health", { timeoutMs: 1_000 });
|
|
61
|
+
if (health.status === 200) break;
|
|
62
|
+
} catch {
|
|
63
|
+
// Server not listening yet.
|
|
64
|
+
}
|
|
65
|
+
await delay(200);
|
|
66
|
+
}
|
|
67
|
+
assert.equal(health?.status, 200, `server should become healthy, output:\n${serverOutput}`);
|
|
68
|
+
assert.equal(health.body.ok, true);
|
|
69
|
+
assert.equal(health.body.piRunning, true, "fake pi RPC process should be attached and running");
|
|
70
|
+
|
|
71
|
+
const tabsResponse = await request("127.0.0.1", "/api/tabs");
|
|
72
|
+
assert.equal(tabsResponse.status, 200);
|
|
73
|
+
const tabList = tabsResponse.body?.data?.tabs || tabsResponse.body?.tabs || [];
|
|
74
|
+
assert.equal(tabList.length, 1, "startup should create one tab for --cwd");
|
|
75
|
+
const tabId = tabList[0].id;
|
|
76
|
+
assert.ok(tabId, "tab should have an id");
|
|
77
|
+
|
|
78
|
+
const state = await request("127.0.0.1", `/api/state?tab=${encodeURIComponent(tabId)}`);
|
|
79
|
+
assert.equal(state.status, 200);
|
|
80
|
+
assert.equal(state.body?.data?.model?.provider, "fake", "state should come from the fake pi RPC");
|
|
81
|
+
|
|
82
|
+
// Native slash command routed through the adapter (/copy → get_last_assistant_text).
|
|
83
|
+
const copy = await request("127.0.0.1", "/api/prompt", {
|
|
84
|
+
method: "POST",
|
|
85
|
+
body: { message: "/copy", tab: tabId },
|
|
86
|
+
});
|
|
87
|
+
assert.equal(copy.status, 200);
|
|
88
|
+
assert.equal(copy.body?.data?.status, "succeeded", "native /copy should succeed through the adapter");
|
|
89
|
+
assert.equal(copy.body?.data?.copyText, "fake last text");
|
|
90
|
+
|
|
91
|
+
// Bash FIFO queue: concurrent requests must execute serially on the RPC.
|
|
92
|
+
const [bashA, bashB] = await Promise.all([
|
|
93
|
+
request("127.0.0.1", "/api/bash", { method: "POST", body: { command: "echo a", tab: tabId }, timeoutMs: 10_000 }),
|
|
94
|
+
request("127.0.0.1", "/api/bash", { method: "POST", body: { command: "echo b", tab: tabId }, timeoutMs: 10_000 }),
|
|
95
|
+
]);
|
|
96
|
+
assert.equal(bashA.status, 200);
|
|
97
|
+
assert.equal(bashB.status, 200);
|
|
98
|
+
for (const result of [bashA, bashB]) {
|
|
99
|
+
assert.equal(result.body?.data?.output, "peak:1", "bash queue must never run two commands concurrently");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Session-dir confinement: traversal targets are rejected even from localhost.
|
|
103
|
+
const traversalDelete = await request("127.0.0.1", "/api/session-delete", {
|
|
104
|
+
method: "POST",
|
|
105
|
+
body: { sessionPath: path.join(cwd, "outside.jsonl"), confirmed: true, tab: tabId },
|
|
106
|
+
});
|
|
107
|
+
assert.equal(traversalDelete.status, 403, "session delete outside the session dir must return 403");
|
|
108
|
+
assert.match(String(traversalDelete.body?.error || ""), /session directory/i);
|
|
109
|
+
|
|
110
|
+
const lan = lanAddress();
|
|
111
|
+
if (lan) {
|
|
112
|
+
const remoteDelete = await request(lan, "/api/session-delete", {
|
|
113
|
+
method: "POST",
|
|
114
|
+
body: { sessionPath: path.join(cwd, "outside.jsonl"), confirmed: true, tab: tabId },
|
|
115
|
+
});
|
|
116
|
+
assert.equal(remoteDelete.status, 403, "session delete must be localhost-only");
|
|
117
|
+
|
|
118
|
+
const remoteExport = await request(lan, "/api/prompt", {
|
|
119
|
+
method: "POST",
|
|
120
|
+
body: { message: "/export", tab: tabId },
|
|
121
|
+
});
|
|
122
|
+
assert.equal(remoteExport.status, 200, "guarded slash commands return blocked adapter cards, not raw HTTP errors");
|
|
123
|
+
assert.equal(remoteExport.body?.data?.status, "blocked", "guards-driven dispatch must block /export for LAN clients");
|
|
124
|
+
|
|
125
|
+
const remoteClose = await request(lan, "/api/network/close", { method: "POST" });
|
|
126
|
+
assert.equal(remoteClose.status, 403, "network close must be localhost-only");
|
|
127
|
+
} else {
|
|
128
|
+
console.log("http-endpoints-harness: no LAN address detected; skipping remote-client checks");
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const localClose = await request("127.0.0.1", "/api/network/close", { method: "POST" });
|
|
132
|
+
assert.equal(localClose.status, 202, "network close from localhost should be accepted");
|
|
133
|
+
|
|
134
|
+
const shutdownResponse = await request("127.0.0.1", "/api/shutdown", { method: "POST" });
|
|
135
|
+
assert.equal(shutdownResponse.status, 200);
|
|
136
|
+
|
|
137
|
+
for (let attempt = 0; attempt < 50 && child.exitCode === null; attempt++) {
|
|
138
|
+
await delay(100);
|
|
139
|
+
}
|
|
140
|
+
assert.notEqual(child.exitCode, null, "server should exit after /api/shutdown");
|
|
141
|
+
} finally {
|
|
142
|
+
if (child.exitCode === null) child.kill("SIGKILL");
|
|
143
|
+
await rm(cwd, { recursive: true, force: true });
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
console.log("http-endpoints-harness.test.mjs passed");
|