@wrongstack/webui 0.8.2 → 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.
@@ -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 === wsToken;
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({ port: wsPort2, host: "::1", verifyClient }) : null;
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 {