@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/index.ts
CHANGED
|
@@ -391,14 +391,25 @@ function commandLooksLikeWebui(command: string, options: StartWebuiOptions): boo
|
|
|
391
391
|
return new RegExp(`(?:^|\\s)--port\\s+${escapedPort}(?:\\s|$)`).test(command);
|
|
392
392
|
}
|
|
393
393
|
|
|
394
|
-
async function
|
|
395
|
-
if (process.platform === "win32")
|
|
394
|
+
async function listProcessCommandLines(): Promise<string> {
|
|
395
|
+
if (process.platform === "win32") {
|
|
396
|
+
// tasklist has no command lines; CIM is the reliable way to find pi-webui.mjs --port matches.
|
|
397
|
+
const result = await runCommand(
|
|
398
|
+
"powershell.exe",
|
|
399
|
+
["-NoProfile", "-NonInteractive", "-Command", 'Get-CimInstance Win32_Process | ForEach-Object { "$($_.ProcessId) $($_.CommandLine)" }'],
|
|
400
|
+
5_000,
|
|
401
|
+
);
|
|
402
|
+
return result.exitCode === 0 ? result.stdout : "";
|
|
403
|
+
}
|
|
396
404
|
let result = await runCommand("ps", ["-Ao", "pid=,args="], 1_500);
|
|
397
405
|
if (result.exitCode !== 0) result = await runCommand("ps", ["-eo", "pid=,args="], 1_500);
|
|
398
|
-
|
|
406
|
+
return result.exitCode === 0 ? result.stdout : "";
|
|
407
|
+
}
|
|
399
408
|
|
|
409
|
+
async function findWebuiPidsByCommand(options: StartWebuiOptions): Promise<number[]> {
|
|
410
|
+
const processList = await listProcessCommandLines();
|
|
400
411
|
const pids: number[] = [];
|
|
401
|
-
for (const line of
|
|
412
|
+
for (const line of processList.split(/\r?\n/)) {
|
|
402
413
|
const match = line.match(/^\s*(\d+)\s+(.+)$/);
|
|
403
414
|
if (!match) continue;
|
|
404
415
|
const pid = Number.parseInt(match[1], 10);
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { AuthStorage, ModelRegistry } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
export function createAuthContext() {
|
|
4
|
+
const authStorage = AuthStorage.create();
|
|
5
|
+
const modelRegistry = ModelRegistry.create(authStorage);
|
|
6
|
+
return { authStorage, modelRegistry };
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function listLoginProviderOptions(modelRegistry) {
|
|
10
|
+
const authStorage = modelRegistry.authStorage;
|
|
11
|
+
const byId = new Map();
|
|
12
|
+
for (const provider of authStorage.getOAuthProviders()) {
|
|
13
|
+
byId.set(provider.id, {
|
|
14
|
+
id: provider.id,
|
|
15
|
+
name: provider.name,
|
|
16
|
+
authType: "oauth",
|
|
17
|
+
removable: authStorage.has(provider.id),
|
|
18
|
+
status: modelRegistry.getProviderAuthStatus(provider.id),
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
for (const model of modelRegistry.getAll()) {
|
|
22
|
+
if (byId.has(model.provider)) continue;
|
|
23
|
+
byId.set(model.provider, {
|
|
24
|
+
id: model.provider,
|
|
25
|
+
name: modelRegistry.getProviderDisplayName(model.provider),
|
|
26
|
+
authType: "api_key",
|
|
27
|
+
removable: authStorage.has(model.provider),
|
|
28
|
+
status: modelRegistry.getProviderAuthStatus(model.provider),
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
return [...byId.values()].sort((left, right) => left.name.localeCompare(right.name));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function listLogoutProviderOptions(modelRegistry) {
|
|
35
|
+
const authStorage = modelRegistry.authStorage;
|
|
36
|
+
const options = [];
|
|
37
|
+
for (const providerId of authStorage.list()) {
|
|
38
|
+
const credential = authStorage.get(providerId);
|
|
39
|
+
if (!credential) continue;
|
|
40
|
+
options.push({
|
|
41
|
+
id: providerId,
|
|
42
|
+
name: modelRegistry.getProviderDisplayName(providerId),
|
|
43
|
+
authType: credential.type,
|
|
44
|
+
status: modelRegistry.getProviderAuthStatus(providerId),
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
return options.sort((left, right) => left.name.localeCompare(right.name));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function authProvidersPayload(modelRegistry) {
|
|
51
|
+
const loginProviders = listLoginProviderOptions(modelRegistry);
|
|
52
|
+
const logoutProviders = listLogoutProviderOptions(modelRegistry);
|
|
53
|
+
return {
|
|
54
|
+
loginProviders,
|
|
55
|
+
logoutProviders,
|
|
56
|
+
storedProviderCount: logoutProviders.length,
|
|
57
|
+
browserLoginSupported: false,
|
|
58
|
+
guidance: [
|
|
59
|
+
"OAuth and API-key login flows still require the Pi TUI /login command.",
|
|
60
|
+
"Web UI logout only removes credentials stored in auth.json by /login.",
|
|
61
|
+
"Environment variables and models.json credentials are not removable from the Web UI.",
|
|
62
|
+
].join("\n"),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function logoutStoredProvider(modelRegistry, providerId) {
|
|
67
|
+
const id = String(providerId || "").trim();
|
|
68
|
+
if (!id) throw new Error("provider is required");
|
|
69
|
+
const authStorage = modelRegistry.authStorage;
|
|
70
|
+
if (!authStorage.has(id)) {
|
|
71
|
+
throw new Error(`No stored credentials found for provider: ${id}`);
|
|
72
|
+
}
|
|
73
|
+
const credential = authStorage.get(id);
|
|
74
|
+
authStorage.logout(id);
|
|
75
|
+
modelRegistry.refresh();
|
|
76
|
+
const name = modelRegistry.getProviderDisplayName(id);
|
|
77
|
+
const message = credential?.type === "oauth"
|
|
78
|
+
? `Logged out of ${name}.`
|
|
79
|
+
: `Removed stored API key for ${name}. Environment variables and models.json config are unchanged.`;
|
|
80
|
+
return { provider: id, providerName: name, authType: credential?.type, message };
|
|
81
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { evaluateTrustGuards, guardsForNativeCommand, isLocalRequest, trustBlockMessage } from "./trust-boundaries.mjs";
|
|
2
|
+
|
|
3
|
+
export const NATIVE_COMMAND_STATUSES = new Set([
|
|
4
|
+
"succeeded",
|
|
5
|
+
"degraded",
|
|
6
|
+
"unavailable",
|
|
7
|
+
"confirmation_required",
|
|
8
|
+
"blocked",
|
|
9
|
+
]);
|
|
10
|
+
|
|
11
|
+
export const NATIVE_RESPONSE_LEVELS = new Set(["info", "warn", "error"]);
|
|
12
|
+
export const NATIVE_REFRESH_TARGETS = new Set(["state", "tabs", "commands", "themes", "workspace"]);
|
|
13
|
+
|
|
14
|
+
export function nativeParitySurfaces(matrix) {
|
|
15
|
+
return Array.isArray(matrix?.surfaces) ? matrix.surfaces : [];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function nativeSlashCommandEntries(matrix) {
|
|
19
|
+
return nativeParitySurfaces(matrix)
|
|
20
|
+
.filter((surface) => surface?.kind === "slash-command")
|
|
21
|
+
.map((surface) => {
|
|
22
|
+
const name = String(surface.command?.name || surface.id || "").replace(/^\//, "").trim();
|
|
23
|
+
return {
|
|
24
|
+
name,
|
|
25
|
+
description: String(surface.command?.description || surface.title || `/${name}`),
|
|
26
|
+
source: "native",
|
|
27
|
+
location: "Pi",
|
|
28
|
+
nativeParity: {
|
|
29
|
+
status: surface.webStatus || "unsupported",
|
|
30
|
+
priority: surface.priority || "P2",
|
|
31
|
+
guards: Array.isArray(surface.guards) ? surface.guards : [],
|
|
32
|
+
sensitive: surface.sensitive === true,
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
})
|
|
36
|
+
.filter((command) => command.name);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function nativeParitySurfaceForCommand(name, matrix) {
|
|
40
|
+
return nativeParitySurfaces(matrix).find((surface) => surface.kind === "slash-command" && surface.command?.name === name) || null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function parseSlashCommand(message, nativeSlashCommandNames) {
|
|
44
|
+
const allowed = nativeSlashCommandNames instanceof Set ? nativeSlashCommandNames : new Set(nativeSlashCommandNames || []);
|
|
45
|
+
const text = String(message || "").trim();
|
|
46
|
+
if (!text.startsWith("/") || text.includes("\n")) return undefined;
|
|
47
|
+
const match = text.match(/^\/([^\s]+)(?:\s+([\s\S]*))?$/);
|
|
48
|
+
if (!match) return undefined;
|
|
49
|
+
const name = match[1].toLowerCase();
|
|
50
|
+
if (!allowed.has(name)) return undefined;
|
|
51
|
+
return { name, args: (match[2] || "").trim(), text };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function rpcSuccess(command, data = {}) {
|
|
55
|
+
return { type: "response", command, success: true, data };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function defaultStatusForSurface(surface) {
|
|
59
|
+
if (!surface) return "unavailable";
|
|
60
|
+
if (surface.webStatus === "implemented") return "succeeded";
|
|
61
|
+
if (surface.webStatus === "degraded") return "degraded";
|
|
62
|
+
return "unavailable";
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function defaultLevelForStatus(status) {
|
|
66
|
+
if (status === "succeeded") return "info";
|
|
67
|
+
if (status === "confirmation_required") return "warn";
|
|
68
|
+
if (status === "blocked") return "error";
|
|
69
|
+
return "warn";
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function normalizeCards(data = {}) {
|
|
73
|
+
const cards = [];
|
|
74
|
+
if (Array.isArray(data.cards)) {
|
|
75
|
+
for (const card of data.cards) {
|
|
76
|
+
if (!card || typeof card !== "object") continue;
|
|
77
|
+
const content = String(card.content || card.message || "").trim();
|
|
78
|
+
if (!content) continue;
|
|
79
|
+
cards.push({
|
|
80
|
+
kind: String(card.kind || "transcript"),
|
|
81
|
+
title: card.title ? String(card.title) : undefined,
|
|
82
|
+
content,
|
|
83
|
+
level: NATIVE_RESPONSE_LEVELS.has(card.level) ? card.level : "info",
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (!cards.length && data.message) {
|
|
88
|
+
cards.push({
|
|
89
|
+
kind: "transcript",
|
|
90
|
+
title: data.command ? `/${data.command}` : undefined,
|
|
91
|
+
content: String(data.message),
|
|
92
|
+
level: NATIVE_RESPONSE_LEVELS.has(data.level) ? data.level : defaultLevelForStatus(data.status),
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
return cards;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function normalizeToasts(data = {}) {
|
|
99
|
+
const toasts = [];
|
|
100
|
+
if (Array.isArray(data.toasts)) {
|
|
101
|
+
for (const toast of data.toasts) {
|
|
102
|
+
if (!toast || typeof toast !== "object") continue;
|
|
103
|
+
const message = String(toast.message || "").trim();
|
|
104
|
+
if (!message) continue;
|
|
105
|
+
toasts.push({
|
|
106
|
+
level: NATIVE_RESPONSE_LEVELS.has(toast.level) ? toast.level : "info",
|
|
107
|
+
message,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return toasts;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function normalizeWarnings(data = {}) {
|
|
115
|
+
const warnings = [];
|
|
116
|
+
if (Array.isArray(data.warnings)) {
|
|
117
|
+
for (const warning of data.warnings) if (warning) warnings.push(String(warning));
|
|
118
|
+
}
|
|
119
|
+
if (data.safetyRestriction) warnings.push(String(data.safetyRestriction));
|
|
120
|
+
return [...new Set(warnings.filter(Boolean))];
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function normalizeRefresh(data = {}) {
|
|
124
|
+
const refresh = [];
|
|
125
|
+
if (Array.isArray(data.refresh)) {
|
|
126
|
+
for (const target of data.refresh) {
|
|
127
|
+
if (NATIVE_REFRESH_TARGETS.has(target)) refresh.push(target);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
if (!refresh.length) {
|
|
131
|
+
if (data.tab) refresh.push("tabs");
|
|
132
|
+
if (data.result || data.copyText || data.download || data.serverPath) refresh.push("state");
|
|
133
|
+
}
|
|
134
|
+
return [...new Set(refresh)];
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function nativeParityMeta(surface) {
|
|
138
|
+
if (!surface) return undefined;
|
|
139
|
+
return {
|
|
140
|
+
webStatus: surface.webStatus,
|
|
141
|
+
priority: surface.priority,
|
|
142
|
+
sensitive: surface.sensitive === true,
|
|
143
|
+
guards: Array.isArray(surface.guards) ? surface.guards : [],
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function nativeCommandResponse(command, data = {}, matrix) {
|
|
148
|
+
const surface = nativeParitySurfaceForCommand(command, matrix);
|
|
149
|
+
const status = NATIVE_COMMAND_STATUSES.has(data.status) ? data.status : defaultStatusForSurface(surface);
|
|
150
|
+
const level = NATIVE_RESPONSE_LEVELS.has(data.level) ? data.level : defaultLevelForStatus(status);
|
|
151
|
+
const cards = normalizeCards({ ...data, command, status, level });
|
|
152
|
+
const toasts = normalizeToasts(data);
|
|
153
|
+
const warnings = normalizeWarnings(data);
|
|
154
|
+
const refresh = normalizeRefresh(data);
|
|
155
|
+
const message = data.message || cards[0]?.content || "";
|
|
156
|
+
|
|
157
|
+
const payload = {
|
|
158
|
+
command,
|
|
159
|
+
status,
|
|
160
|
+
level,
|
|
161
|
+
message,
|
|
162
|
+
nativeParity: nativeParityMeta(surface),
|
|
163
|
+
cards,
|
|
164
|
+
toasts,
|
|
165
|
+
warnings,
|
|
166
|
+
refresh,
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
for (const [key, value] of Object.entries(data)) {
|
|
170
|
+
if (["status", "level", "message", "cards", "toasts", "warnings", "refresh"].includes(key)) continue;
|
|
171
|
+
if (value !== undefined) payload[key] = value;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return rpcSuccess("native_slash_command", payload);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function nativeCommandUnavailable(command, details = {}, matrix) {
|
|
178
|
+
const surface = nativeParitySurfaceForCommand(command, matrix);
|
|
179
|
+
const guards = Array.isArray(surface?.guards) ? surface.guards.filter((guard) => guard !== "none") : [];
|
|
180
|
+
const reason = details.reason || surface?.currentBehavior || "This native Pi TUI command is not implemented in the Web UI yet.";
|
|
181
|
+
const nextActions = details.nextActions || [
|
|
182
|
+
surface?.targetBehavior ? `Planned Web UI behavior: ${surface.targetBehavior}` : "Use the Pi TUI for this command until Web UI parity is implemented.",
|
|
183
|
+
];
|
|
184
|
+
return nativeCommandResponse(
|
|
185
|
+
command,
|
|
186
|
+
{
|
|
187
|
+
status: "unavailable",
|
|
188
|
+
level: "warn",
|
|
189
|
+
reason,
|
|
190
|
+
safetyRestriction: details.safetyRestriction || (guards.length ? `Guarded by: ${guards.join(", ")}.` : undefined),
|
|
191
|
+
nextActions,
|
|
192
|
+
message: details.message || [`/${command} is not available in the Web UI yet.`, reason, ...nextActions].filter(Boolean).join("\n"),
|
|
193
|
+
refresh: details.refresh,
|
|
194
|
+
...details,
|
|
195
|
+
},
|
|
196
|
+
matrix,
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function nativeCommandBlocked(command, req, matrix, options = {}) {
|
|
201
|
+
const guards = guardsForNativeCommand(command, matrix);
|
|
202
|
+
const evaluation = evaluateTrustGuards(guards, {
|
|
203
|
+
isLocal: isLocalRequest(req),
|
|
204
|
+
confirmed: options.confirmed === true,
|
|
205
|
+
networkOpen: options.networkOpen === true,
|
|
206
|
+
});
|
|
207
|
+
return nativeCommandResponse(
|
|
208
|
+
command,
|
|
209
|
+
{
|
|
210
|
+
status: "blocked",
|
|
211
|
+
level: "error",
|
|
212
|
+
reason: trustBlockMessage(command, evaluation.blocked),
|
|
213
|
+
safetyRestriction: evaluation.blocked.length ? `Blocked guards: ${evaluation.blocked.join(", ")}.` : undefined,
|
|
214
|
+
warnings: evaluation.warnings,
|
|
215
|
+
message: trustBlockMessage(command, evaluation.blocked),
|
|
216
|
+
refresh: [],
|
|
217
|
+
},
|
|
218
|
+
matrix,
|
|
219
|
+
);
|
|
220
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { existsSync, realpathSync } from "node:fs";
|
|
3
|
+
import { stat, unlink } from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { SessionManager } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
|
|
7
|
+
const SESSION_DIR_BLOCK_MESSAGE = "sessionPath must stay inside the Pi session directory";
|
|
8
|
+
|
|
9
|
+
export function normalizeSessionFilePath(value) {
|
|
10
|
+
const text = String(value || "").trim();
|
|
11
|
+
return text ? path.resolve(text) : "";
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Resolve symlinks where possible so confinement checks compare canonical paths. */
|
|
15
|
+
function canonicalSessionPath(value) {
|
|
16
|
+
const resolved = normalizeSessionFilePath(value);
|
|
17
|
+
if (!resolved) return "";
|
|
18
|
+
try {
|
|
19
|
+
return realpathSync(resolved);
|
|
20
|
+
} catch {
|
|
21
|
+
return resolved;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Empty/missing allowedDirs means no confinement is configured for this call. */
|
|
26
|
+
export function isSessionPathAllowed(sessionPath, allowedDirs = []) {
|
|
27
|
+
const dirs = (allowedDirs || []).map((dir) => canonicalSessionPath(dir)).filter(Boolean);
|
|
28
|
+
if (!dirs.length) return true;
|
|
29
|
+
const target = canonicalSessionPath(sessionPath);
|
|
30
|
+
if (!target) return false;
|
|
31
|
+
return dirs.some((dir) => {
|
|
32
|
+
const relative = path.relative(dir, target);
|
|
33
|
+
return Boolean(relative) && !relative.startsWith("..") && !path.isAbsolute(relative);
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function collectOpenSessionFiles(tabs = []) {
|
|
38
|
+
const files = new Set();
|
|
39
|
+
for (const tab of tabs) {
|
|
40
|
+
for (const candidate of [tab?.lastState?.sessionFile, tab?.sessionFile]) {
|
|
41
|
+
const normalized = normalizeSessionFilePath(candidate);
|
|
42
|
+
if (normalized) files.add(normalized);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return files;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function validateSessionDelete(sessionPath, { openSessionFiles, currentSessionFile, confirmed, allowedDirs } = {}) {
|
|
49
|
+
if (confirmed !== true) {
|
|
50
|
+
return {
|
|
51
|
+
allowed: false,
|
|
52
|
+
reason: "confirmation_required",
|
|
53
|
+
message: "Session delete requires explicit confirmation (confirmed: true).",
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const target = normalizeSessionFilePath(sessionPath);
|
|
58
|
+
if (!target) {
|
|
59
|
+
return { allowed: false, reason: "invalid_path", message: "sessionPath is required" };
|
|
60
|
+
}
|
|
61
|
+
if (!target.endsWith(".jsonl")) {
|
|
62
|
+
return { allowed: false, reason: "invalid_path", message: "sessionPath must point to a .jsonl session file" };
|
|
63
|
+
}
|
|
64
|
+
if (!isSessionPathAllowed(target, allowedDirs)) {
|
|
65
|
+
return { allowed: false, reason: "outside_session_dir", message: SESSION_DIR_BLOCK_MESSAGE };
|
|
66
|
+
}
|
|
67
|
+
if (openSessionFiles?.has(target)) {
|
|
68
|
+
return {
|
|
69
|
+
allowed: false,
|
|
70
|
+
reason: "session_in_use",
|
|
71
|
+
message: "Cannot delete a session that is open in a Web UI tab. Close that tab first.",
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
const activePath = normalizeSessionFilePath(currentSessionFile);
|
|
75
|
+
if (activePath && activePath === target) {
|
|
76
|
+
return {
|
|
77
|
+
allowed: false,
|
|
78
|
+
reason: "active_session",
|
|
79
|
+
message: "Cannot delete the active session for this tab. Switch to another session first.",
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
return { allowed: true, sessionPath: target };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export async function renameSessionMetadata(sessionPath, name, sessionDir, { allowedDirs } = {}) {
|
|
86
|
+
const trimmed = String(name || "").trim();
|
|
87
|
+
if (!trimmed) throw new Error("name is required");
|
|
88
|
+
const targetPath = normalizeSessionFilePath(sessionPath);
|
|
89
|
+
if (!targetPath.endsWith(".jsonl")) throw new Error("sessionPath must point to a .jsonl session file");
|
|
90
|
+
if (!isSessionPathAllowed(targetPath, allowedDirs)) throw new Error(SESSION_DIR_BLOCK_MESSAGE);
|
|
91
|
+
const targetStats = await stat(targetPath).catch(() => null);
|
|
92
|
+
if (!targetStats?.isFile()) throw new Error(`Session file not found: ${targetPath}`);
|
|
93
|
+
|
|
94
|
+
const manager = SessionManager.open(targetPath, sessionDir);
|
|
95
|
+
const previousName = manager.getSessionName();
|
|
96
|
+
const entryId = manager.appendSessionInfo(trimmed);
|
|
97
|
+
return {
|
|
98
|
+
sessionPath: manager.getSessionFile() || targetPath,
|
|
99
|
+
name: trimmed,
|
|
100
|
+
entryId,
|
|
101
|
+
previousName,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export async function deleteSessionFile(sessionPath, { allowedDirs } = {}) {
|
|
106
|
+
const targetPath = normalizeSessionFilePath(sessionPath);
|
|
107
|
+
if (!targetPath) throw new Error("sessionPath is required");
|
|
108
|
+
if (!isSessionPathAllowed(targetPath, allowedDirs)) throw new Error(SESSION_DIR_BLOCK_MESSAGE);
|
|
109
|
+
const targetStats = await stat(targetPath).catch(() => null);
|
|
110
|
+
if (!targetStats?.isFile()) throw new Error(`Session file not found: ${targetPath}`);
|
|
111
|
+
|
|
112
|
+
const trashArgs = targetPath.startsWith("-") ? ["--", targetPath] : [targetPath];
|
|
113
|
+
const trashResult = spawnSync("trash", trashArgs, { encoding: "utf8" });
|
|
114
|
+
const trashHint = () => {
|
|
115
|
+
const parts = [];
|
|
116
|
+
if (trashResult.error) parts.push(trashResult.error.message);
|
|
117
|
+
const stderr = String(trashResult.stderr || "").trim();
|
|
118
|
+
if (stderr) parts.push(stderr.split("\n")[0] || stderr);
|
|
119
|
+
return parts.length ? `trash: ${parts.join(" · ").slice(0, 200)}` : "";
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
if (trashResult.status === 0 || !existsSync(targetPath)) {
|
|
123
|
+
return { sessionPath: targetPath, method: "trash" };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
try {
|
|
127
|
+
await unlink(targetPath);
|
|
128
|
+
return { sessionPath: targetPath, method: "unlink" };
|
|
129
|
+
} catch (error) {
|
|
130
|
+
const unlinkError = error instanceof Error ? error.message : String(error);
|
|
131
|
+
const hint = trashHint();
|
|
132
|
+
throw new Error(hint ? `${unlinkError} (${hint})` : unlinkError);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { readdir, rm, stat } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Remove direct children (files or directories) of dir whose mtime is older
|
|
6
|
+
* than ttlMs. Used to reclaim pi-webui upload/native-export temp artifacts;
|
|
7
|
+
* a missing dir or racing removals are not errors.
|
|
8
|
+
*
|
|
9
|
+
* @returns {Promise<string[]>} absolute paths that were removed
|
|
10
|
+
*/
|
|
11
|
+
export async function sweepStaleTempEntries(dir, { ttlMs, now = Date.now() } = {}) {
|
|
12
|
+
const removed = [];
|
|
13
|
+
if (!Number.isFinite(ttlMs) || ttlMs < 0) return removed;
|
|
14
|
+
|
|
15
|
+
let entries;
|
|
16
|
+
try {
|
|
17
|
+
entries = await readdir(dir);
|
|
18
|
+
} catch {
|
|
19
|
+
return removed;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
for (const name of entries) {
|
|
23
|
+
const entryPath = path.join(dir, name);
|
|
24
|
+
try {
|
|
25
|
+
const stats = await stat(entryPath);
|
|
26
|
+
if (now - stats.mtimeMs <= ttlMs) continue;
|
|
27
|
+
await rm(entryPath, { recursive: true, force: true });
|
|
28
|
+
removed.push(entryPath);
|
|
29
|
+
} catch {
|
|
30
|
+
// Entry vanished mid-sweep or is not removable; leave it for the next pass.
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return removed;
|
|
34
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
export const TRUST_GUARD_TYPES = new Set([
|
|
2
|
+
"none",
|
|
3
|
+
"confirmation",
|
|
4
|
+
"localhost",
|
|
5
|
+
"trusted-context",
|
|
6
|
+
"feature-flag",
|
|
7
|
+
"upstream-rpc",
|
|
8
|
+
"read-only",
|
|
9
|
+
]);
|
|
10
|
+
|
|
11
|
+
/** HTTP POST routes that mutate server state and require a localhost client. */
|
|
12
|
+
export const LOCALHOST_ONLY_POST_ROUTES = new Map([
|
|
13
|
+
["/api/network/open", "Opening to the network is only allowed from localhost"],
|
|
14
|
+
["/api/network/close", "Closing network access is only allowed from localhost"],
|
|
15
|
+
["/api/restart", "Restart is only allowed from localhost"],
|
|
16
|
+
["/api/update", "Updating Pi from the Web UI is only allowed from localhost"],
|
|
17
|
+
["/api/shutdown", "Shutdown is only allowed from localhost"],
|
|
18
|
+
["/api/optional-feature-install", "Installing optional Web UI features is only allowed from localhost"],
|
|
19
|
+
["/api/skill-file", "Saving skill files is only allowed from localhost"],
|
|
20
|
+
["/api/session-delete", "Deleting sessions is only allowed from localhost"],
|
|
21
|
+
["/api/auth-logout", "Removing stored provider credentials is only allowed from localhost"],
|
|
22
|
+
]);
|
|
23
|
+
|
|
24
|
+
export const REMOTE_SHELL_WARNING =
|
|
25
|
+
"This Web UI client is not on localhost. Shell commands run on the server as the Web UI process user.";
|
|
26
|
+
|
|
27
|
+
/** Guards enforced before native slash commands run; confirmation stays handler-specific. */
|
|
28
|
+
export const DISPATCH_TRUST_GUARDS = new Set(["localhost", "trusted-context"]);
|
|
29
|
+
|
|
30
|
+
export function isLocalAddress(address = "") {
|
|
31
|
+
const normalized = String(address || "").trim().toLowerCase();
|
|
32
|
+
if (!normalized) return false;
|
|
33
|
+
if (normalized === "::1" || normalized === "localhost") return true;
|
|
34
|
+
if (normalized.startsWith("127.")) return true;
|
|
35
|
+
if (normalized.startsWith("::ffff:127.")) return true;
|
|
36
|
+
return normalized === "::ffff:127.0.0.1";
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function isLocalRequest(req) {
|
|
40
|
+
return isLocalAddress(req?.socket?.remoteAddress);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function makeTrustError(statusCode, message, details = {}) {
|
|
44
|
+
const error = new Error(message);
|
|
45
|
+
error.statusCode = statusCode;
|
|
46
|
+
error.trust = details;
|
|
47
|
+
return error;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function requireLocalhost(req, message = "This action is only allowed from localhost") {
|
|
51
|
+
if (!isLocalRequest(req)) throw makeTrustError(403, message, { guard: "localhost" });
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function requireLocalhostRoute(req, pathname) {
|
|
55
|
+
const message = LOCALHOST_ONLY_POST_ROUTES.get(pathname);
|
|
56
|
+
if (message) requireLocalhost(req, message);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function nativeParitySurfaces(matrix) {
|
|
60
|
+
return Array.isArray(matrix?.surfaces) ? matrix.surfaces : [];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function guardsForNativeCommand(commandName, matrix) {
|
|
64
|
+
const surface = nativeParitySurfaces(matrix).find(
|
|
65
|
+
(item) => item?.kind === "slash-command" && item.command?.name === commandName,
|
|
66
|
+
);
|
|
67
|
+
return Array.isArray(surface?.guards) ? surface.guards : ["none"];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function evaluateDispatchTrustGuards(guards, context = {}) {
|
|
71
|
+
const relevant = (guards || []).filter((guard) => DISPATCH_TRUST_GUARDS.has(guard));
|
|
72
|
+
if (!relevant.length) return { allowed: true, blocked: [], warnings: [] };
|
|
73
|
+
return evaluateTrustGuards(relevant, context);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function evaluateTrustGuards(guards, context = {}) {
|
|
77
|
+
const blocked = [];
|
|
78
|
+
const warnings = [];
|
|
79
|
+
const isLocal = context.isLocal === true;
|
|
80
|
+
const confirmed = context.confirmed === true;
|
|
81
|
+
const networkOpen = context.networkOpen === true;
|
|
82
|
+
|
|
83
|
+
for (const guard of guards || []) {
|
|
84
|
+
if (!TRUST_GUARD_TYPES.has(guard)) continue;
|
|
85
|
+
switch (guard) {
|
|
86
|
+
case "none":
|
|
87
|
+
case "read-only":
|
|
88
|
+
case "upstream-rpc":
|
|
89
|
+
case "feature-flag":
|
|
90
|
+
break;
|
|
91
|
+
case "localhost":
|
|
92
|
+
if (!isLocal) blocked.push(guard);
|
|
93
|
+
break;
|
|
94
|
+
case "trusted-context":
|
|
95
|
+
if (!isLocal) blocked.push(guard);
|
|
96
|
+
break;
|
|
97
|
+
case "confirmation":
|
|
98
|
+
if (!confirmed) blocked.push(guard);
|
|
99
|
+
break;
|
|
100
|
+
default:
|
|
101
|
+
break;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (!isLocal && networkOpen && (guards || []).some((guard) => guard === "trusted-context" || guard === "localhost")) {
|
|
106
|
+
warnings.push(REMOTE_SHELL_WARNING);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return { allowed: blocked.length === 0, blocked, warnings };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function trustBlockMessage(commandName, blockedGuards = []) {
|
|
113
|
+
if (blockedGuards.includes("localhost") || blockedGuards.includes("trusted-context")) {
|
|
114
|
+
return `/${commandName} is blocked for non-localhost browser clients. Connect via localhost or use the Pi TUI.`;
|
|
115
|
+
}
|
|
116
|
+
if (blockedGuards.includes("confirmation")) {
|
|
117
|
+
return `/${commandName} requires explicit confirmation before it can run from the Web UI.`;
|
|
118
|
+
}
|
|
119
|
+
return `/${commandName} is blocked by Web UI trust policy.`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function assertNativeCommandTrust(req, commandName, matrix, options = {}) {
|
|
123
|
+
const guards = guardsForNativeCommand(commandName, matrix);
|
|
124
|
+
const evaluation = evaluateDispatchTrustGuards(guards, {
|
|
125
|
+
isLocal: isLocalRequest(req),
|
|
126
|
+
confirmed: options.confirmed === true,
|
|
127
|
+
networkOpen: options.networkOpen === true,
|
|
128
|
+
});
|
|
129
|
+
if (evaluation.allowed) return evaluation;
|
|
130
|
+
throw makeTrustError(403, trustBlockMessage(commandName, evaluation.blocked), {
|
|
131
|
+
guard: evaluation.blocked[0],
|
|
132
|
+
command: commandName,
|
|
133
|
+
blocked: evaluation.blocked,
|
|
134
|
+
warnings: evaluation.warnings,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function remoteShellTrustWarning(req, networkOpen = false) {
|
|
139
|
+
if (isLocalRequest(req) || !networkOpen) return undefined;
|
|
140
|
+
return REMOTE_SHELL_WARNING;
|
|
141
|
+
}
|