@wrongstack/webui 0.8.4 → 0.8.5
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/dist/server/entry.js +349 -10
- package/dist/server/entry.js.map +1 -1
- package/dist/server/index.js +349 -10
- package/dist/server/index.js.map +1 -1
- package/package.json +5 -5
package/dist/server/entry.js
CHANGED
|
@@ -33,7 +33,7 @@ import { decryptConfigSecrets, encryptConfigSecrets } from "@wrongstack/core/sec
|
|
|
33
33
|
import { buildProviderFactoriesFromRegistry, makeProviderFromConfig } from "@wrongstack/providers";
|
|
34
34
|
import { builtinToolsPack, forgetTool, rememberTool } from "@wrongstack/tools";
|
|
35
35
|
import { WebSocket, WebSocketServer } from "ws";
|
|
36
|
-
import { randomBytes } from "crypto";
|
|
36
|
+
import { randomBytes, timingSafeEqual } from "crypto";
|
|
37
37
|
|
|
38
38
|
// ../runtime/src/container.ts
|
|
39
39
|
import {
|
|
@@ -135,7 +135,310 @@ function patchConfig(config, updates) {
|
|
|
135
135
|
return Object.freeze({ ...config, ...updates });
|
|
136
136
|
}
|
|
137
137
|
|
|
138
|
+
// src/server/autophase-ws-handler.ts
|
|
139
|
+
import {
|
|
140
|
+
AutoPhasePlanner,
|
|
141
|
+
PhaseGraphBuilder,
|
|
142
|
+
PhaseOrchestrator,
|
|
143
|
+
PhaseStore
|
|
144
|
+
} from "@wrongstack/core";
|
|
145
|
+
var AutoPhaseWebSocketHandler = class {
|
|
146
|
+
constructor(agent, context, logger, storeDir) {
|
|
147
|
+
this.agent = agent;
|
|
148
|
+
this.context = context;
|
|
149
|
+
this.logger = logger;
|
|
150
|
+
this.store = new PhaseStore({ baseDir: storeDir });
|
|
151
|
+
}
|
|
152
|
+
agent;
|
|
153
|
+
context;
|
|
154
|
+
logger;
|
|
155
|
+
orchestrator = null;
|
|
156
|
+
graph = null;
|
|
157
|
+
store;
|
|
158
|
+
clients = /* @__PURE__ */ new Set();
|
|
159
|
+
broadcastInterval = null;
|
|
160
|
+
/** Aborts in-flight task agents when the run is stopped. */
|
|
161
|
+
abort = null;
|
|
162
|
+
addClient(ws) {
|
|
163
|
+
const client = { ws, id: crypto.randomUUID() };
|
|
164
|
+
this.clients.add(client);
|
|
165
|
+
ws.on("close", () => this.clients.delete(client));
|
|
166
|
+
ws.on("error", () => this.clients.delete(client));
|
|
167
|
+
this.sendState(client);
|
|
168
|
+
}
|
|
169
|
+
async handleMessage(msg) {
|
|
170
|
+
switch (msg.type) {
|
|
171
|
+
case "autophase.start":
|
|
172
|
+
await this.handleStart(msg.payload);
|
|
173
|
+
break;
|
|
174
|
+
case "autophase.pause":
|
|
175
|
+
this.orchestrator?.pause();
|
|
176
|
+
this.broadcast({ type: "autophase.paused", payload: {} });
|
|
177
|
+
break;
|
|
178
|
+
case "autophase.resume":
|
|
179
|
+
this.orchestrator?.resume();
|
|
180
|
+
this.broadcast({ type: "autophase.resumed", payload: {} });
|
|
181
|
+
break;
|
|
182
|
+
case "autophase.stop":
|
|
183
|
+
this.abort?.abort();
|
|
184
|
+
this.orchestrator?.stop();
|
|
185
|
+
this.stopBroadcast();
|
|
186
|
+
if (this.graph) void this.store.save(this.graph);
|
|
187
|
+
this.broadcast({ type: "autophase.stopped", payload: {} });
|
|
188
|
+
break;
|
|
189
|
+
case "autophase.status":
|
|
190
|
+
this.broadcastState();
|
|
191
|
+
break;
|
|
192
|
+
case "autophase.selectPhase": {
|
|
193
|
+
const phaseId = msg.payload?.phaseId;
|
|
194
|
+
if (phaseId && this.graph) {
|
|
195
|
+
this.broadcastState(phaseId);
|
|
196
|
+
}
|
|
197
|
+
break;
|
|
198
|
+
}
|
|
199
|
+
case "autophase.taskStatus": {
|
|
200
|
+
const { taskId, status } = msg.payload;
|
|
201
|
+
await this.handleTaskStatusChange(taskId, status);
|
|
202
|
+
break;
|
|
203
|
+
}
|
|
204
|
+
case "autophase.toggleAutonomous": {
|
|
205
|
+
const autonomous = msg.payload?.autonomous ?? !this.graph?.autonomous;
|
|
206
|
+
if (this.graph) {
|
|
207
|
+
this.graph.autonomous = autonomous;
|
|
208
|
+
await this.store.save(this.graph);
|
|
209
|
+
this.broadcast({ type: "autophase.state", payload: this.buildState() });
|
|
210
|
+
}
|
|
211
|
+
break;
|
|
212
|
+
}
|
|
213
|
+
case "autophase.save": {
|
|
214
|
+
if (this.graph) {
|
|
215
|
+
await this.store.save(this.graph);
|
|
216
|
+
this.broadcast({ type: "autophase.saved", payload: { graphId: this.graph.id } });
|
|
217
|
+
}
|
|
218
|
+
break;
|
|
219
|
+
}
|
|
220
|
+
case "autophase.list": {
|
|
221
|
+
const graphs = await this.store.list();
|
|
222
|
+
this.broadcast({ type: "autophase.list", payload: { graphs } });
|
|
223
|
+
break;
|
|
224
|
+
}
|
|
225
|
+
case "autophase.load": {
|
|
226
|
+
const graphId = msg.payload?.graphId;
|
|
227
|
+
if (graphId) {
|
|
228
|
+
const graph = await this.store.load(graphId);
|
|
229
|
+
if (graph) {
|
|
230
|
+
this.graph = graph;
|
|
231
|
+
this.broadcast({ type: "autophase.state", payload: this.buildState() });
|
|
232
|
+
} else {
|
|
233
|
+
this.broadcast({ type: "autophase.error", payload: { message: `Graph not found: ${graphId}` } });
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
break;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
async handleStart(payload) {
|
|
241
|
+
const title = payload?.goal || payload?.title || "Untitled Project";
|
|
242
|
+
const autonomous = payload?.autonomous ?? true;
|
|
243
|
+
const phases = Array.isArray(payload?.phases) ? payload.phases : await this.planPhases(title);
|
|
244
|
+
this.logger.info(`[AutoPhase] Starting: ${title}`);
|
|
245
|
+
const graph = await new PhaseGraphBuilder({ title, phases, autonomous }).build();
|
|
246
|
+
this.graph = graph;
|
|
247
|
+
this.abort = new AbortController();
|
|
248
|
+
await this.store.save(graph);
|
|
249
|
+
this.orchestrator = new PhaseOrchestrator({
|
|
250
|
+
graph,
|
|
251
|
+
ctx: {
|
|
252
|
+
executeTask: async (task, phaseId) => {
|
|
253
|
+
this.logger.info(`[AutoPhase] [${phaseId}] Executing: ${task.title}`);
|
|
254
|
+
const result = await this.executeTaskWithAgent(task, phaseId);
|
|
255
|
+
this.logger.info(`[AutoPhase] [${phaseId}] Completed: ${task.title}`);
|
|
256
|
+
return result;
|
|
257
|
+
},
|
|
258
|
+
onPhaseComplete: (phase) => {
|
|
259
|
+
this.logger.info(`[AutoPhase] Phase completed: ${phase.name}`);
|
|
260
|
+
void this.store.save(graph);
|
|
261
|
+
this.broadcastState();
|
|
262
|
+
},
|
|
263
|
+
onPhaseFail: (phase, error) => {
|
|
264
|
+
this.logger.error(`[AutoPhase] Phase failed: ${phase.name} \u2014 ${error.message}`);
|
|
265
|
+
void this.store.save(graph);
|
|
266
|
+
this.broadcastState();
|
|
267
|
+
}
|
|
268
|
+
},
|
|
269
|
+
autonomous,
|
|
270
|
+
maxConcurrentPhases: 1,
|
|
271
|
+
// Sequential within a phase: each todo is a full-tool agent editing the
|
|
272
|
+
// shared working tree, so running two at once risks concurrent writes.
|
|
273
|
+
maxConcurrentTasks: 1
|
|
274
|
+
});
|
|
275
|
+
this.startBroadcast();
|
|
276
|
+
this.broadcastState();
|
|
277
|
+
void this.orchestrator.start().then(() => {
|
|
278
|
+
this.orchestrator?.stop();
|
|
279
|
+
void this.store.save(graph);
|
|
280
|
+
this.stopBroadcast();
|
|
281
|
+
const failed = graph.failedPhaseIds.length > 0;
|
|
282
|
+
this.broadcast(
|
|
283
|
+
failed ? { type: "autophase.failed", payload: { title } } : { type: "autophase.completed", payload: { title } }
|
|
284
|
+
);
|
|
285
|
+
this.broadcastState();
|
|
286
|
+
}).catch((err) => {
|
|
287
|
+
this.logger.error(`[AutoPhase] Aborted: ${err instanceof Error ? err.message : String(err)}`);
|
|
288
|
+
this.stopBroadcast();
|
|
289
|
+
this.broadcast({ type: "autophase.failed", payload: { title, error: String(err) } });
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
/** Generic fallback phases when the LLM planner produces nothing usable. */
|
|
293
|
+
defaultPhases() {
|
|
294
|
+
return [
|
|
295
|
+
{ name: "Discovery", description: "Requirements gathering", priority: "high", estimateHours: 2, parallelizable: false },
|
|
296
|
+
{ name: "Design", description: "Architecture and design", priority: "critical", estimateHours: 4, parallelizable: false },
|
|
297
|
+
{ name: "Implementation", description: "Core development", priority: "critical", estimateHours: 12, parallelizable: false },
|
|
298
|
+
{ name: "Testing", description: "Unit and integration tests", priority: "high", estimateHours: 6, parallelizable: true },
|
|
299
|
+
{ name: "Deployment", description: "Deploy to production", priority: "medium", estimateHours: 2, parallelizable: false }
|
|
300
|
+
];
|
|
301
|
+
}
|
|
302
|
+
/** Plan phases+todos for the goal via the LLM; fall back to defaults on failure. */
|
|
303
|
+
async planPhases(goal) {
|
|
304
|
+
try {
|
|
305
|
+
const planner = new AutoPhasePlanner({
|
|
306
|
+
goal,
|
|
307
|
+
runOnce: async (prompt) => {
|
|
308
|
+
const result = await this.agent.run(prompt, { signal: new AbortController().signal });
|
|
309
|
+
return result.status === "done" ? result.finalText ?? "" : "";
|
|
310
|
+
}
|
|
311
|
+
});
|
|
312
|
+
const { phases, parseFailed } = await planner.plan();
|
|
313
|
+
if (!parseFailed && phases.length > 0) {
|
|
314
|
+
const todos = phases.reduce((n, p) => n + (p.taskTemplates?.length ?? 0), 0);
|
|
315
|
+
this.logger.info(`[AutoPhase] Planned ${phases.length} phases / ${todos} todos for: ${goal}`);
|
|
316
|
+
return phases;
|
|
317
|
+
}
|
|
318
|
+
this.logger.info(`[AutoPhase] Planner produced no phases; using defaults for: ${goal}`);
|
|
319
|
+
} catch (err) {
|
|
320
|
+
this.logger.error(`[AutoPhase] Planning failed, using defaults: ${err instanceof Error ? err.message : String(err)}`);
|
|
321
|
+
}
|
|
322
|
+
return this.defaultPhases();
|
|
323
|
+
}
|
|
324
|
+
async executeTaskWithAgent(task, phaseId) {
|
|
325
|
+
const prompt = `Execute task: ${task.title}
|
|
326
|
+
|
|
327
|
+
Description: ${task.description}
|
|
328
|
+
Phase: ${phaseId}
|
|
329
|
+
Priority: ${task.priority}
|
|
330
|
+
Type: ${task.type}`;
|
|
331
|
+
const signal = this.abort?.signal ?? new AbortController().signal;
|
|
332
|
+
const result = await this.agent.run(prompt, { signal });
|
|
333
|
+
return result;
|
|
334
|
+
}
|
|
335
|
+
async handleTaskStatusChange(taskId, status) {
|
|
336
|
+
if (!this.graph) return;
|
|
337
|
+
for (const phase of this.graph.phases.values()) {
|
|
338
|
+
const task = phase.taskGraph.nodes.get(taskId);
|
|
339
|
+
if (task) {
|
|
340
|
+
task.status = status;
|
|
341
|
+
task.updatedAt = Date.now();
|
|
342
|
+
this.broadcastState();
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
startBroadcast() {
|
|
348
|
+
if (this.broadcastInterval) return;
|
|
349
|
+
this.broadcastInterval = setInterval(() => {
|
|
350
|
+
const progress = this.orchestrator?.getProgress();
|
|
351
|
+
if (progress) this.broadcast({ type: "autophase.progress", payload: progress });
|
|
352
|
+
this.broadcastState();
|
|
353
|
+
}, 2e3);
|
|
354
|
+
}
|
|
355
|
+
stopBroadcast() {
|
|
356
|
+
if (this.broadcastInterval) {
|
|
357
|
+
clearInterval(this.broadcastInterval);
|
|
358
|
+
this.broadcastInterval = null;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
broadcastState(activePhaseId) {
|
|
362
|
+
if (!this.graph) return;
|
|
363
|
+
const state = this.buildState(activePhaseId);
|
|
364
|
+
this.broadcast({ type: "autophase.state", payload: state });
|
|
365
|
+
}
|
|
366
|
+
buildState(activePhaseId) {
|
|
367
|
+
if (!this.graph) {
|
|
368
|
+
return { phases: [], tasks: [], overallPercent: 0, autonomous: true, title: "" };
|
|
369
|
+
}
|
|
370
|
+
const phases = Array.from(this.graph.phases.values());
|
|
371
|
+
const currentActiveId = activePhaseId || phases.find((p) => p.status === "running")?.id || phases[0]?.id || "";
|
|
372
|
+
const activePhase = this.graph.phases.get(currentActiveId);
|
|
373
|
+
const totalTasks = phases.reduce((sum, p) => sum + p.taskGraph.nodes.size, 0);
|
|
374
|
+
const completedTasks = phases.reduce(
|
|
375
|
+
(sum, p) => sum + Array.from(p.taskGraph.nodes.values()).filter((t) => t.status === "completed").length,
|
|
376
|
+
0
|
|
377
|
+
);
|
|
378
|
+
const phaseItems = phases.map((p) => ({
|
|
379
|
+
id: p.id,
|
|
380
|
+
name: p.name,
|
|
381
|
+
description: p.description,
|
|
382
|
+
status: p.status,
|
|
383
|
+
priority: p.priority,
|
|
384
|
+
estimateHours: p.estimateHours,
|
|
385
|
+
actualDurationMs: p.actualDurationMs,
|
|
386
|
+
startedAt: p.startedAt,
|
|
387
|
+
completedAt: p.completedAt,
|
|
388
|
+
progressPercent: p.taskGraph.nodes.size > 0 ? Math.round(Array.from(p.taskGraph.nodes.values()).filter((t) => t.status === "completed").length / p.taskGraph.nodes.size * 100) : 0,
|
|
389
|
+
taskCount: p.taskGraph.nodes.size,
|
|
390
|
+
completedTasks: Array.from(p.taskGraph.nodes.values()).filter((t) => t.status === "completed").length,
|
|
391
|
+
assignedAgents: p.assignedAgents,
|
|
392
|
+
isActive: p.id === currentActiveId
|
|
393
|
+
}));
|
|
394
|
+
const taskItems = activePhase ? Array.from(activePhase.taskGraph.nodes.values()).map((t) => ({
|
|
395
|
+
id: t.id,
|
|
396
|
+
title: t.title,
|
|
397
|
+
description: t.description,
|
|
398
|
+
status: t.status,
|
|
399
|
+
priority: t.priority,
|
|
400
|
+
type: t.type,
|
|
401
|
+
estimateHours: t.estimateHours,
|
|
402
|
+
actualHours: t.actualHours,
|
|
403
|
+
assignee: t.assignee,
|
|
404
|
+
tags: t.tags || [],
|
|
405
|
+
startedAt: t.startedAt,
|
|
406
|
+
completedAt: t.completedAt
|
|
407
|
+
})) : [];
|
|
408
|
+
const completedPhases = phases.filter((p) => p.status === "completed").length;
|
|
409
|
+
return {
|
|
410
|
+
title: this.graph.title,
|
|
411
|
+
phases: phaseItems,
|
|
412
|
+
tasks: taskItems,
|
|
413
|
+
activePhaseId: currentActiveId,
|
|
414
|
+
overallPercent: phases.length > 0 ? Math.round(completedPhases / phases.length * 100) : 0,
|
|
415
|
+
autonomous: this.graph.autonomous,
|
|
416
|
+
totalTasks,
|
|
417
|
+
completedTasks
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
sendState(client) {
|
|
421
|
+
if (!this.graph) return;
|
|
422
|
+
const state = this.buildState();
|
|
423
|
+
this.send(client, { type: "autophase.state", payload: state });
|
|
424
|
+
}
|
|
425
|
+
broadcast(msg) {
|
|
426
|
+
const data = JSON.stringify(msg);
|
|
427
|
+
for (const client of this.clients) {
|
|
428
|
+
if (client.ws.readyState === 1) {
|
|
429
|
+
client.ws.send(data);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
send(client, msg) {
|
|
434
|
+
if (client.ws.readyState === 1) {
|
|
435
|
+
client.ws.send(JSON.stringify(msg));
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
};
|
|
439
|
+
|
|
138
440
|
// src/server/index.ts
|
|
441
|
+
var HTML_CSP = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self' ws: wss:; img-src 'self' data:; font-src 'self' data:; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'";
|
|
139
442
|
async function startWebUI(opts = {}) {
|
|
140
443
|
const wsPort2 = opts.wsPort ?? 3457;
|
|
141
444
|
const wsHost2 = opts.wsHost ?? "127.0.0.1";
|
|
@@ -337,6 +640,7 @@ async function startWebUI(opts = {}) {
|
|
|
337
640
|
toolExecutor
|
|
338
641
|
});
|
|
339
642
|
console.log("[WebUI] Agent initialized");
|
|
643
|
+
const autoPhaseHandler = new AutoPhaseWebSocketHandler(agent, context, logger, wpaths.projectAutophase);
|
|
340
644
|
async function sessionStartPayload() {
|
|
341
645
|
let maxContext = 0;
|
|
342
646
|
let inputCost = 0;
|
|
@@ -369,12 +673,33 @@ async function startWebUI(opts = {}) {
|
|
|
369
673
|
const wsToken = randomBytes(16).toString("hex");
|
|
370
674
|
console.log(`[WebUI] WS auth token: ${wsToken.slice(0, 4)}\u2026${wsToken.slice(-4)} (masked)`);
|
|
371
675
|
const isLoopback = (hostname) => hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1" || hostname === "[::1]";
|
|
676
|
+
const tokenMatches = (provided) => {
|
|
677
|
+
if (!provided) return false;
|
|
678
|
+
const a = Buffer.from(provided);
|
|
679
|
+
const b = Buffer.from(wsToken);
|
|
680
|
+
if (a.length !== b.length) return false;
|
|
681
|
+
return timingSafeEqual(a, b);
|
|
682
|
+
};
|
|
683
|
+
const hostHeaderOk = (req) => {
|
|
684
|
+
const boundToLoopback = wsHost2 === "127.0.0.1" || wsHost2 === "::1" || wsHost2 === "localhost";
|
|
685
|
+
if (!boundToLoopback) return true;
|
|
686
|
+
const hostHeader = (req.headers.host ?? "").trim();
|
|
687
|
+
if (!hostHeader) return false;
|
|
688
|
+
let hostname;
|
|
689
|
+
try {
|
|
690
|
+
hostname = new URL(`http://${hostHeader}`).hostname;
|
|
691
|
+
} catch {
|
|
692
|
+
return false;
|
|
693
|
+
}
|
|
694
|
+
return isLoopback(hostname);
|
|
695
|
+
};
|
|
372
696
|
const verifyClient = (info) => {
|
|
373
697
|
const origin = info.origin;
|
|
374
698
|
const url = info.req.url ?? "";
|
|
375
699
|
const tokenMatch = url.match(/[?&]token=([^&]+)/);
|
|
376
700
|
const providedToken = tokenMatch ? tokenMatch[1] : void 0;
|
|
377
|
-
const tokenOk = providedToken
|
|
701
|
+
const tokenOk = tokenMatches(providedToken);
|
|
702
|
+
if (!hostHeaderOk(info.req)) return false;
|
|
378
703
|
if (!origin) {
|
|
379
704
|
const remoteIp = info.req.socket.remoteAddress ?? "";
|
|
380
705
|
const isRemoteLoopback = remoteIp === "127.0.0.1" || remoteIp === "::1";
|
|
@@ -384,18 +709,24 @@ async function startWebUI(opts = {}) {
|
|
|
384
709
|
try {
|
|
385
710
|
const { hostname } = new URL(origin);
|
|
386
711
|
if (isLoopback(hostname)) return true;
|
|
387
|
-
if (wsHost2 === "0.0.0.0") return tokenOk;
|
|
388
712
|
return tokenOk;
|
|
389
713
|
} catch {
|
|
390
714
|
return false;
|
|
391
715
|
}
|
|
392
716
|
};
|
|
717
|
+
const WS_MAX_PAYLOAD = 8 * 1024 * 1024;
|
|
393
718
|
const wssPrimary = new WebSocketServer({
|
|
394
719
|
port: wsPort2,
|
|
395
720
|
host: wsHost2,
|
|
396
|
-
verifyClient
|
|
721
|
+
verifyClient,
|
|
722
|
+
maxPayload: WS_MAX_PAYLOAD
|
|
397
723
|
});
|
|
398
|
-
const wssSecondary = wsHost2 === "127.0.0.1" ? new WebSocketServer({
|
|
724
|
+
const wssSecondary = wsHost2 === "127.0.0.1" ? new WebSocketServer({
|
|
725
|
+
port: wsPort2,
|
|
726
|
+
host: "::1",
|
|
727
|
+
verifyClient,
|
|
728
|
+
maxPayload: WS_MAX_PAYLOAD
|
|
729
|
+
}) : null;
|
|
399
730
|
const clients = /* @__PURE__ */ new Map();
|
|
400
731
|
const RATE_LIMIT_MESSAGES = 60;
|
|
401
732
|
const RATE_LIMIT_WINDOW_MS = 6e4;
|
|
@@ -530,6 +861,7 @@ async function startWebUI(opts = {}) {
|
|
|
530
861
|
void sessionStartPayload().then((payload) => {
|
|
531
862
|
send(ws, { type: "session.start", payload });
|
|
532
863
|
});
|
|
864
|
+
autoPhaseHandler.addClient(ws);
|
|
533
865
|
ws.on("message", async (data) => {
|
|
534
866
|
if (!checkRateLimit(ws)) {
|
|
535
867
|
send(ws, {
|
|
@@ -1342,6 +1674,12 @@ async function startWebUI(opts = {}) {
|
|
|
1342
1674
|
});
|
|
1343
1675
|
break;
|
|
1344
1676
|
}
|
|
1677
|
+
default:
|
|
1678
|
+
if (msg.type.startsWith("autophase.")) {
|
|
1679
|
+
await autoPhaseHandler.handleMessage(msg);
|
|
1680
|
+
} else {
|
|
1681
|
+
send(ws, { type: "error", payload: { phase: "handleMessage", message: `Unknown message type: ${msg.type}` } });
|
|
1682
|
+
}
|
|
1345
1683
|
}
|
|
1346
1684
|
}
|
|
1347
1685
|
async function loadSavedProviders() {
|
|
@@ -1538,10 +1876,7 @@ async function startWebUI(opts = {}) {
|
|
|
1538
1876
|
res.setHeader("Referrer-Policy", "strict-origin-when-cross-origin");
|
|
1539
1877
|
if (ext === ".html") {
|
|
1540
1878
|
res.setHeader("Cache-Control", "no-cache");
|
|
1541
|
-
res.setHeader(
|
|
1542
|
-
"Content-Security-Policy",
|
|
1543
|
-
"default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; connect-src 'self' ws: wss:; img-src 'self' data:; font-src 'self' data:"
|
|
1544
|
-
);
|
|
1879
|
+
res.setHeader("Content-Security-Policy", HTML_CSP);
|
|
1545
1880
|
}
|
|
1546
1881
|
const fileContent = await fs2.readFile(resolvedPath);
|
|
1547
1882
|
res.writeHead(200);
|
|
@@ -1553,7 +1888,11 @@ async function startWebUI(opts = {}) {
|
|
|
1553
1888
|
res.writeHead(200, {
|
|
1554
1889
|
"Content-Type": "text/html",
|
|
1555
1890
|
"X-Content-Type-Options": "nosniff",
|
|
1556
|
-
"X-Frame-Options": "DENY"
|
|
1891
|
+
"X-Frame-Options": "DENY",
|
|
1892
|
+
"Referrer-Policy": "strict-origin-when-cross-origin",
|
|
1893
|
+
// SPA fallback previously shipped no CSP — apply the same policy as
|
|
1894
|
+
// the direct .html branch so deep-linked routes aren't unprotected.
|
|
1895
|
+
"Content-Security-Policy": HTML_CSP
|
|
1557
1896
|
});
|
|
1558
1897
|
res.end(fileContent);
|
|
1559
1898
|
} catch {
|