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