patchwarden 1.5.0 → 1.5.1

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.
Files changed (217) hide show
  1. package/README.en.md +43 -2
  2. package/README.md +43 -2
  3. package/dist/assessments/agentAssessor.d.ts +1 -1
  4. package/dist/assessments/agentAssessor.js +3 -3
  5. package/dist/assessments/assessmentStore.d.ts +1 -1
  6. package/dist/assessments/assessmentStore.js +2 -2
  7. package/dist/assessments/confirmCli.js +5 -4
  8. package/dist/config.d.ts +7 -0
  9. package/dist/config.js +34 -0
  10. package/dist/control/middleware/auth.d.ts +10 -0
  11. package/dist/control/middleware/auth.js +8 -0
  12. package/dist/control/middleware/static.d.ts +3 -0
  13. package/dist/control/middleware/static.js +65 -0
  14. package/dist/control/routes/audit.d.ts +15 -0
  15. package/dist/control/routes/audit.js +277 -0
  16. package/dist/control/routes/evidence.d.ts +4 -0
  17. package/dist/control/routes/evidence.js +96 -0
  18. package/dist/control/routes/lineage.d.ts +3 -0
  19. package/dist/control/routes/lineage.js +71 -0
  20. package/dist/control/routes/policy.d.ts +3 -0
  21. package/dist/control/routes/policy.js +81 -0
  22. package/dist/control/routes/process.d.ts +5 -0
  23. package/dist/control/routes/process.js +200 -0
  24. package/dist/control/routes/sessions.d.ts +22 -0
  25. package/dist/control/routes/sessions.js +224 -0
  26. package/dist/control/routes/status.d.ts +6 -0
  27. package/dist/control/routes/status.js +250 -0
  28. package/dist/control/routes/taskActions.d.ts +21 -0
  29. package/dist/control/routes/taskActions.js +233 -0
  30. package/dist/control/routes/tasks.d.ts +20 -0
  31. package/dist/control/routes/tasks.js +310 -0
  32. package/dist/control/routes/workspace.d.ts +15 -0
  33. package/dist/control/routes/workspace.js +193 -0
  34. package/dist/control/runtime.d.ts +91 -0
  35. package/dist/control/runtime.js +392 -0
  36. package/dist/control/server.d.ts +13 -0
  37. package/dist/control/server.js +479 -0
  38. package/dist/control/shared.d.ts +35 -0
  39. package/dist/control/shared.js +288 -0
  40. package/dist/controlCenter.d.ts +6 -0
  41. package/dist/controlCenter.js +7 -2197
  42. package/dist/direct/directGuards.js +30 -8
  43. package/dist/doctor.d.ts +18 -1
  44. package/dist/doctor.js +579 -348
  45. package/dist/goal/goalReport.d.ts +54 -0
  46. package/dist/goal/goalReport.js +204 -0
  47. package/dist/goal/goalStatus.d.ts +6 -0
  48. package/dist/goal/specKitImport.d.ts +63 -0
  49. package/dist/goal/specKitImport.js +220 -0
  50. package/dist/goal/subgoalSync.js +2 -1
  51. package/dist/httpServer.js +15 -12
  52. package/dist/index.js +7 -4
  53. package/dist/logging.d.ts +7 -1
  54. package/dist/logging.js +8 -0
  55. package/dist/runner/changeCapture.d.ts +3 -3
  56. package/dist/runner/changeCapture.js +63 -39
  57. package/dist/runner/cli.js +7 -6
  58. package/dist/runner/postTaskCleanup.js +26 -2
  59. package/dist/runner/runTask.js +245 -221
  60. package/dist/runner/simpleProcess.js +4 -4
  61. package/dist/runner/watch.js +17 -14
  62. package/dist/security/contentRedaction.d.ts +6 -0
  63. package/dist/security/contentRedaction.js +22 -0
  64. package/dist/smoke-test.js +257 -251
  65. package/dist/test/unit/apply-patch.test.d.ts +1 -0
  66. package/dist/test/unit/apply-patch.test.js +225 -0
  67. package/dist/test/unit/create-task.test.d.ts +1 -0
  68. package/dist/test/unit/create-task.test.js +197 -0
  69. package/dist/test/unit/direct-guards.test.js +124 -9
  70. package/dist/test/unit/evidence-pack.test.js +95 -1
  71. package/dist/test/unit/get-task-status.test.d.ts +1 -0
  72. package/dist/test/unit/get-task-status.test.js +174 -0
  73. package/dist/test/unit/get-task-summary.test.d.ts +1 -0
  74. package/dist/test/unit/get-task-summary.test.js +146 -0
  75. package/dist/test/unit/goal-report.test.d.ts +1 -0
  76. package/dist/test/unit/goal-report.test.js +159 -0
  77. package/dist/test/unit/goal-subgoal-task.test.js +6 -6
  78. package/dist/test/unit/goal-tools-registry.test.js +6 -4
  79. package/dist/test/unit/path-guard.test.js +24 -0
  80. package/dist/test/unit/spec-kit-import.test.d.ts +1 -0
  81. package/dist/test/unit/spec-kit-import.test.js +341 -0
  82. package/dist/test/unit/wait-for-task.test.d.ts +1 -0
  83. package/dist/test/unit/wait-for-task.test.js +144 -0
  84. package/dist/tools/auditTask.d.ts +8 -63
  85. package/dist/tools/auditTask.js +12 -8
  86. package/dist/tools/createDirectSession.d.ts +1 -1
  87. package/dist/tools/createDirectSession.js +2 -2
  88. package/dist/tools/createTask.d.ts +2 -2
  89. package/dist/tools/createTask.js +4 -4
  90. package/dist/tools/dispatch/coreDispatch.d.ts +9 -0
  91. package/dist/tools/dispatch/coreDispatch.js +282 -0
  92. package/dist/tools/dispatch/diagnosticDispatch.d.ts +14 -0
  93. package/dist/tools/dispatch/diagnosticDispatch.js +78 -0
  94. package/dist/tools/dispatch/directDispatch.d.ts +8 -0
  95. package/dist/tools/dispatch/directDispatch.js +115 -0
  96. package/dist/tools/dispatch/goalDispatch.d.ts +8 -0
  97. package/dist/tools/dispatch/goalDispatch.js +91 -0
  98. package/dist/tools/dispatch/releaseDispatch.d.ts +8 -0
  99. package/dist/tools/dispatch/releaseDispatch.js +45 -0
  100. package/dist/tools/dispatch/types.d.ts +23 -0
  101. package/dist/tools/dispatch/types.js +15 -0
  102. package/dist/tools/evidencePack.d.ts +6 -0
  103. package/dist/tools/evidencePack.js +213 -6
  104. package/dist/tools/finalizeDirectSession.d.ts +1 -1
  105. package/dist/tools/finalizeDirectSession.js +3 -3
  106. package/dist/tools/goalSubgoalTask.d.ts +1 -1
  107. package/dist/tools/goalSubgoalTask.js +2 -2
  108. package/dist/tools/healthCheck.js +3 -3
  109. package/dist/tools/registry.d.ts +3 -3
  110. package/dist/tools/registry.js +60 -503
  111. package/dist/tools/retryTask.d.ts +2 -2
  112. package/dist/tools/retryTask.js +2 -2
  113. package/dist/tools/runTaskLoop.js +4 -4
  114. package/dist/tools/safeViews.d.ts +2 -2
  115. package/dist/tools/safeViews.js +2 -2
  116. package/dist/tools/toolRegistry.js +22 -0
  117. package/dist/version.d.ts +1 -1
  118. package/dist/version.js +1 -1
  119. package/docs/CODE_WIKI.md +983 -0
  120. package/docs/agentseal-integration.md +150 -0
  121. package/docs/architecture.md +63 -0
  122. package/docs/assets/patchwarden-oss-demo.gif +0 -0
  123. package/docs/dashboard-overview.md +86 -0
  124. package/docs/demo.md +8 -0
  125. package/docs/direct-session-workflow.md +98 -0
  126. package/docs/evidence-pack-schema.md +215 -0
  127. package/docs/execution-plan-2026-07-09.md +315 -0
  128. package/docs/lineage-evidence-pack-workflow.md +127 -0
  129. package/docs/mcp-inspector-testing.md +200 -0
  130. package/docs/open-source-application.md +162 -0
  131. package/docs/opencode-worker.md +151 -0
  132. package/docs/openhands-worker.md +181 -0
  133. package/docs/release-evidence.md +72 -0
  134. package/docs/roadmap-execution-and-acceptance.md +365 -0
  135. package/docs/spec-kit-integration.md +131 -0
  136. package/docs/task-safe-review-workflow.md +98 -0
  137. package/docs/threat-model.md +79 -0
  138. package/docs/user-feedback.md +40 -0
  139. package/docs/why-patchwarden.md +110 -0
  140. package/package.json +2 -2
  141. package/scripts/checks/control-center-smoke.js +356 -0
  142. package/scripts/checks/lifecycle-smoke.js +29 -23
  143. package/scripts/checks/mcp-smoke.js +3 -1
  144. package/scripts/generate-demo-gif.py +320 -0
  145. package/src/assessments/agentAssessor.ts +3 -3
  146. package/src/assessments/assessmentStore.ts +2 -2
  147. package/src/assessments/confirmCli.ts +5 -4
  148. package/src/config.ts +37 -0
  149. package/src/control/middleware/auth.ts +17 -0
  150. package/src/control/middleware/static.ts +71 -0
  151. package/src/control/routes/audit.ts +321 -0
  152. package/src/control/routes/evidence.ts +107 -0
  153. package/src/control/routes/lineage.ts +92 -0
  154. package/src/control/routes/policy.ts +81 -0
  155. package/src/control/routes/process.ts +204 -0
  156. package/src/control/routes/sessions.ts +251 -0
  157. package/src/control/routes/status.ts +325 -0
  158. package/src/control/routes/taskActions.ts +248 -0
  159. package/src/control/routes/tasks.ts +323 -0
  160. package/src/control/routes/workspace.ts +203 -0
  161. package/src/control/runtime.ts +472 -0
  162. package/src/control/server.ts +471 -0
  163. package/src/control/shared.ts +294 -0
  164. package/src/controlCenter.ts +7 -2347
  165. package/src/direct/directGuards.ts +28 -7
  166. package/src/doctor.ts +741 -481
  167. package/src/goal/goalReport.ts +271 -0
  168. package/src/goal/goalStatus.ts +6 -0
  169. package/src/goal/specKitImport.ts +355 -0
  170. package/src/goal/subgoalSync.ts +4 -2
  171. package/src/httpServer.ts +17 -14
  172. package/src/index.ts +7 -4
  173. package/src/logging.ts +10 -1
  174. package/src/runner/changeCapture.ts +70 -42
  175. package/src/runner/cli.ts +7 -6
  176. package/src/runner/postTaskCleanup.ts +26 -2
  177. package/src/runner/runTask.ts +325 -223
  178. package/src/runner/simpleProcess.ts +4 -4
  179. package/src/runner/watch.ts +17 -14
  180. package/src/security/contentRedaction.ts +29 -0
  181. package/src/smoke-test.ts +252 -250
  182. package/src/test/unit/apply-patch.test.ts +293 -0
  183. package/src/test/unit/create-task.test.ts +255 -0
  184. package/src/test/unit/direct-guards.test.ts +178 -8
  185. package/src/test/unit/evidence-pack.test.ts +110 -1
  186. package/src/test/unit/get-task-status.test.ts +203 -0
  187. package/src/test/unit/get-task-summary.test.ts +173 -0
  188. package/src/test/unit/goal-report.test.ts +189 -0
  189. package/src/test/unit/goal-subgoal-task.test.ts +6 -6
  190. package/src/test/unit/goal-tools-registry.test.ts +7 -5
  191. package/src/test/unit/path-guard.test.ts +30 -0
  192. package/src/test/unit/spec-kit-import.test.ts +429 -0
  193. package/src/test/unit/wait-for-task.test.ts +176 -0
  194. package/src/tools/auditTask.ts +99 -59
  195. package/src/tools/createDirectSession.ts +3 -3
  196. package/src/tools/createTask.ts +7 -7
  197. package/src/tools/dispatch/coreDispatch.ts +374 -0
  198. package/src/tools/dispatch/diagnosticDispatch.ts +101 -0
  199. package/src/tools/dispatch/directDispatch.ts +167 -0
  200. package/src/tools/dispatch/goalDispatch.ts +127 -0
  201. package/src/tools/dispatch/releaseDispatch.ts +65 -0
  202. package/src/tools/dispatch/types.ts +24 -0
  203. package/src/tools/evidencePack.ts +291 -6
  204. package/src/tools/finalizeDirectSession.ts +4 -4
  205. package/src/tools/goalSubgoalTask.ts +2 -2
  206. package/src/tools/healthCheck.ts +3 -3
  207. package/src/tools/registry.ts +68 -628
  208. package/src/tools/retryTask.ts +2 -2
  209. package/src/tools/runTaskLoop.ts +4 -4
  210. package/src/tools/safeViews.ts +2 -2
  211. package/src/tools/toolRegistry.ts +22 -0
  212. package/src/version.ts +1 -1
  213. package/ui/pages/audit.html +192 -3
  214. package/ui/pages/dashboard.html +959 -44
  215. package/ui/pages/direct-sessions.html +505 -53
  216. package/ui/pages/task-detail.html +456 -438
  217. package/ui/pages/tasks.html +598 -61
@@ -0,0 +1,471 @@
1
+ /**
2
+ * PatchWarden Control Center — HTTP server creation, routing, and lifecycle.
3
+ *
4
+ * This module wires together the route handlers (routes/*.ts), middleware
5
+ * (auth/static), and the shared/runtime helpers. It owns the `handleRequest`
6
+ * router that dispatches every GET/POST route, the POST control-token gate,
7
+ * and the server bootstrap/shutdown lifecycle (status file, activity events,
8
+ * SIGINT/SIGTERM).
9
+ *
10
+ * Entry point: `controlCenter.ts` imports `startServer()` from here.
11
+ */
12
+ import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
13
+ import { PATCHWARDEN_VERSION, TOOL_SCHEMA_EPOCH } from "../version.js";
14
+ import { logger } from "../logging.js";
15
+ import {
16
+ config,
17
+ controlToken,
18
+ errorMessage,
19
+ host,
20
+ PAGE_ALIASES,
21
+ port,
22
+ readBody,
23
+ resolveTailParam,
24
+ sendJson,
25
+ } from "./shared.js";
26
+ import { recordEvent, removeStatusFile, writeStatusFile } from "./runtime.js";
27
+ import { checkControlToken } from "./middleware/auth.js";
28
+ import { serveFavicon, serveStatic } from "./middleware/static.js";
29
+ import { handleTasks, handleStaleTasks, handleTaskDetail, handleTaskSafeResult, handleTaskSafeAudit, handleTaskSafeTestSummary, handleTaskSafeDiffSummary } from "./routes/tasks.js";
30
+ import { handleReconcile, handleTaskAudit, handleOpenTaskFolder, handleHideStale } from "./routes/taskActions.js";
31
+ import { handleDirectSessions, handleDirectSessionDetail, handleDirectSessionSafeSummary, handleDirectSessionFinalize, handleDirectSessionAudit, handleDirectSessionHide } from "./routes/sessions.js";
32
+ import { handleLineages, handleLineageDetail } from "./routes/lineage.js";
33
+ import { handleEvidencePacks, handleEvidencePackDetail, handleEvidencePackExport } from "./routes/evidence.js";
34
+ import { handleProjectPolicy, handleReleaseStatus } from "./routes/policy.js";
35
+ import { handleWorkspace, handleWorkspaceRepos, handleWorkspaceRepoStatus } from "./routes/workspace.js";
36
+ import { handleManageAction, handleOpenLogsFolder } from "./routes/process.js";
37
+ import { handleStatus, handleControlCenterStatus, handleEvents, handleTunnelUiUrl, handleDiagnostics } from "./routes/status.js";
38
+ import { handleLogs, handleAudit, handleWarnings, type LogCategory } from "./routes/audit.js";
39
+
40
+ // ── Request router ────────────────────────────────────────────────
41
+
42
+ async function handleRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {
43
+ const parsedUrl = new URL(req.url || "/", `http://${req.headers.host || host}`);
44
+ const pathname = parsedUrl.pathname;
45
+ const method = (req.method || "GET").toUpperCase();
46
+
47
+ // Static routes
48
+ if (method === "GET" && pathname === "/") {
49
+ serveStatic(res, "pages/dashboard.html");
50
+ return;
51
+ }
52
+ const pageAlias = PAGE_ALIASES[pathname];
53
+ if (method === "GET" && pageAlias) {
54
+ serveStatic(res, pageAlias);
55
+ return;
56
+ }
57
+ if (method === "GET" && pathname === "/control-token.json") {
58
+ sendJson(res, 200, { token: controlToken });
59
+ return;
60
+ }
61
+ if (method === "GET" && pathname === "/favicon.ico") {
62
+ serveFavicon(res);
63
+ return;
64
+ }
65
+ if (
66
+ method === "GET" &&
67
+ (pathname === "/colors_and_type.css" ||
68
+ pathname.startsWith("/pages/") ||
69
+ pathname.startsWith("/partials/") ||
70
+ pathname.startsWith("/vendor/"))
71
+ ) {
72
+ serveStatic(res, pathname);
73
+ return;
74
+ }
75
+
76
+ // GET API routes
77
+ if (method === "GET" && pathname === "/api/status") {
78
+ await handleStatus(res);
79
+ return;
80
+ }
81
+ if (method === "GET" && pathname === "/api/tasks") {
82
+ handleTasks(res, {
83
+ repo_path: parsedUrl.searchParams.get("repo_path") || undefined,
84
+ status: parsedUrl.searchParams.get("status") || undefined,
85
+ acceptance_status: parsedUrl.searchParams.get("acceptance_status") || undefined,
86
+ agent: parsedUrl.searchParams.get("agent") || undefined,
87
+ warning_type: parsedUrl.searchParams.get("warning_type") || undefined,
88
+ });
89
+ return;
90
+ }
91
+ if (method === "GET" && pathname === "/api/tasks/stale") {
92
+ handleStaleTasks(res);
93
+ return;
94
+ }
95
+ if (method === "GET" && pathname === "/api/lineages") {
96
+ handleLineages(res);
97
+ return;
98
+ }
99
+ const lineageMatch = pathname.match(/^\/api\/lineages\/([^/]+)$/);
100
+ if (method === "GET" && lineageMatch) {
101
+ let lineageId: string;
102
+ try {
103
+ lineageId = decodeURIComponent(lineageMatch[1]);
104
+ } catch {
105
+ lineageId = lineageMatch[1];
106
+ }
107
+ handleLineageDetail(res, lineageId);
108
+ return;
109
+ }
110
+ if (method === "GET" && pathname === "/api/project-policy") {
111
+ handleProjectPolicy(res, parsedUrl.searchParams.get("repo_path") || ".");
112
+ return;
113
+ }
114
+ if (method === "GET" && pathname === "/api/release/status") {
115
+ handleReleaseStatus(res, parsedUrl.searchParams.get("repo_path") || ".");
116
+ return;
117
+ }
118
+ if (method === "GET" && pathname === "/api/evidence-packs") {
119
+ handleEvidencePacks(res);
120
+ return;
121
+ }
122
+ const evidencePackMatch = pathname.match(/^\/api\/evidence-packs\/([^/]+)$/);
123
+ if (method === "GET" && evidencePackMatch) {
124
+ let lineageId: string;
125
+ try {
126
+ lineageId = decodeURIComponent(evidencePackMatch[1]);
127
+ } catch {
128
+ lineageId = evidencePackMatch[1];
129
+ }
130
+ handleEvidencePackDetail(res, lineageId);
131
+ return;
132
+ }
133
+ const taskMatch = pathname.match(/^\/api\/tasks\/([^/]+)$/);
134
+ if (method === "GET" && taskMatch) {
135
+ let taskId: string;
136
+ try {
137
+ taskId = decodeURIComponent(taskMatch[1]);
138
+ } catch {
139
+ taskId = taskMatch[1];
140
+ }
141
+ if (
142
+ taskId === "." ||
143
+ taskId === ".." ||
144
+ taskId.includes("/") ||
145
+ taskId.includes("\\") ||
146
+ taskId.includes("\0")
147
+ ) {
148
+ sendJson(res, 400, { error: "Invalid task id" });
149
+ return;
150
+ }
151
+ handleTaskDetail(res, taskId);
152
+ return;
153
+ }
154
+ // Safe, bounded views for task artifacts (no full stdout/stderr/diff).
155
+ const taskSafeResultMatch = pathname.match(/^\/api\/tasks\/([^/]+)\/safe-result$/);
156
+ if (method === "GET" && taskSafeResultMatch) {
157
+ let taskId: string;
158
+ try {
159
+ taskId = decodeURIComponent(taskSafeResultMatch[1]);
160
+ } catch {
161
+ taskId = taskSafeResultMatch[1];
162
+ }
163
+ handleTaskSafeResult(res, taskId);
164
+ return;
165
+ }
166
+ const taskSafeAuditMatch = pathname.match(/^\/api\/tasks\/([^/]+)\/safe-audit$/);
167
+ if (method === "GET" && taskSafeAuditMatch) {
168
+ let taskId: string;
169
+ try {
170
+ taskId = decodeURIComponent(taskSafeAuditMatch[1]);
171
+ } catch {
172
+ taskId = taskSafeAuditMatch[1];
173
+ }
174
+ handleTaskSafeAudit(res, taskId);
175
+ return;
176
+ }
177
+ const taskSafeTestSummaryMatch = pathname.match(/^\/api\/tasks\/([^/]+)\/safe-test-summary$/);
178
+ if (method === "GET" && taskSafeTestSummaryMatch) {
179
+ let taskId: string;
180
+ try {
181
+ taskId = decodeURIComponent(taskSafeTestSummaryMatch[1]);
182
+ } catch {
183
+ taskId = taskSafeTestSummaryMatch[1];
184
+ }
185
+ handleTaskSafeTestSummary(res, taskId);
186
+ return;
187
+ }
188
+ const taskSafeDiffSummaryMatch = pathname.match(/^\/api\/tasks\/([^/]+)\/safe-diff-summary$/);
189
+ if (method === "GET" && taskSafeDiffSummaryMatch) {
190
+ let taskId: string;
191
+ try {
192
+ taskId = decodeURIComponent(taskSafeDiffSummaryMatch[1]);
193
+ } catch {
194
+ taskId = taskSafeDiffSummaryMatch[1];
195
+ }
196
+ handleTaskSafeDiffSummary(res, taskId);
197
+ return;
198
+ }
199
+ // Logs: /api/logs/<category>?tail=<100|300|1000>
200
+ const logsMatch = pathname.match(/^\/api\/logs\/([a-z-]+)$/);
201
+ if (method === "GET" && logsMatch) {
202
+ const rawCat = logsMatch[1];
203
+ const category = rawCat === "core" || rawCat === "direct" || rawCat === "watcher" || rawCat === "control-center"
204
+ ? (rawCat as LogCategory)
205
+ : null;
206
+ if (!category) {
207
+ sendJson(res, 404, { error: "Unknown log category" });
208
+ return;
209
+ }
210
+ const tail = resolveTailParam(parsedUrl.searchParams.get("tail"));
211
+ handleLogs(res, category, tail);
212
+ return;
213
+ }
214
+ if (method === "GET" && pathname === "/api/workspace") {
215
+ handleWorkspace(res);
216
+ return;
217
+ }
218
+ if (method === "GET" && pathname === "/api/workspace/repos") {
219
+ handleWorkspaceRepos(res);
220
+ return;
221
+ }
222
+ // On-demand git status for a single repo (path-traversal safe).
223
+ // The repo segment is URL-decoded; traversal is rejected by guardWorkspacePath.
224
+ const workspaceRepoMatch = pathname.match(/^\/api\/workspace\/([^/]+(?:\/[^/]+)*)\/status$/);
225
+ if (method === "GET" && workspaceRepoMatch) {
226
+ let repoParam: string;
227
+ try {
228
+ repoParam = decodeURIComponent(workspaceRepoMatch[1]);
229
+ } catch {
230
+ sendJson(res, 400, { error: "Invalid repo path encoding" });
231
+ return;
232
+ }
233
+ handleWorkspaceRepoStatus(res, repoParam);
234
+ return;
235
+ }
236
+ if (method === "GET" && pathname === "/api/direct-sessions") {
237
+ handleDirectSessions(res);
238
+ return;
239
+ }
240
+ const directSessionSummaryMatch = pathname.match(/^\/api\/direct-sessions\/([^/]+)\/summary$/);
241
+ if (method === "GET" && directSessionSummaryMatch) {
242
+ let sessionId: string;
243
+ try {
244
+ sessionId = decodeURIComponent(directSessionSummaryMatch[1]);
245
+ } catch {
246
+ sessionId = directSessionSummaryMatch[1];
247
+ }
248
+ handleDirectSessionSafeSummary(res, sessionId);
249
+ return;
250
+ }
251
+ const directSessionMatch = pathname.match(/^\/api\/direct-sessions\/([^/]+)$/);
252
+ if (method === "GET" && directSessionMatch) {
253
+ let sessionId: string;
254
+ try {
255
+ sessionId = decodeURIComponent(directSessionMatch[1]);
256
+ } catch {
257
+ sessionId = directSessionMatch[1];
258
+ }
259
+ handleDirectSessionDetail(res, sessionId);
260
+ return;
261
+ }
262
+ if (method === "GET" && pathname === "/api/audit") {
263
+ handleAudit(res);
264
+ return;
265
+ }
266
+ if (method === "GET" && pathname === "/api/warnings") {
267
+ handleWarnings(res);
268
+ return;
269
+ }
270
+ if (method === "GET" && pathname === "/api/diagnostics") {
271
+ handleDiagnostics(res);
272
+ return;
273
+ }
274
+ if (method === "GET" && pathname === "/api/tunnel-ui-url") {
275
+ handleTunnelUiUrl(res);
276
+ return;
277
+ }
278
+ if (method === "GET" && pathname === "/api/events") {
279
+ const limitParam = parsedUrl.searchParams.get("limit");
280
+ let limit = 100;
281
+ if (limitParam !== null) {
282
+ const n = parseInt(limitParam, 10);
283
+ if (Number.isFinite(n) && n > 0 && n <= 1000) limit = n;
284
+ }
285
+ handleEvents(res, limit);
286
+ return;
287
+ }
288
+ if (method === "GET" && pathname === "/api/control-center-status") {
289
+ handleControlCenterStatus(res);
290
+ return;
291
+ }
292
+
293
+ // POST API routes (all require control token)
294
+ if (method === "POST") {
295
+ await readBody(req); // drain optional body
296
+ if (!checkControlToken(req)) {
297
+ sendJson(res, 403, { error: "Missing or invalid control token" });
298
+ return;
299
+ }
300
+ if (pathname === "/api/start-all") return handleManageAction(res, "start", "all");
301
+ if (pathname === "/api/stop-all") return handleManageAction(res, "stop", "all");
302
+ if (pathname === "/api/restart-all") return handleManageAction(res, "restart", "all");
303
+ if (pathname === "/api/core/start") return handleManageAction(res, "start", "core");
304
+ if (pathname === "/api/core/stop") return handleManageAction(res, "stop", "core");
305
+ if (pathname === "/api/direct/start") return handleManageAction(res, "start", "direct");
306
+ if (pathname === "/api/direct/stop") return handleManageAction(res, "stop", "direct");
307
+ if (pathname === "/api/open-logs-folder") {
308
+ handleOpenLogsFolder(res);
309
+ return;
310
+ }
311
+ // POST /api/direct-sessions/:sessionId/finalize — finalize a direct session
312
+ // (must be matched BEFORE any generic /api/direct-sessions/:sessionId pattern)
313
+ const finalizeDirectMatch = pathname.match(/^\/api\/direct-sessions\/([^/]+)\/finalize$/);
314
+ if (finalizeDirectMatch) {
315
+ let sessionId: string;
316
+ try {
317
+ sessionId = decodeURIComponent(finalizeDirectMatch[1]);
318
+ } catch {
319
+ sessionId = finalizeDirectMatch[1];
320
+ }
321
+ await handleDirectSessionFinalize(res, sessionId);
322
+ return;
323
+ }
324
+ // POST /api/direct-sessions/:sessionId/audit — audit a direct session
325
+ const auditDirectMatch = pathname.match(/^\/api\/direct-sessions\/([^/]+)\/audit$/);
326
+ if (auditDirectMatch) {
327
+ let sessionId: string;
328
+ try {
329
+ sessionId = decodeURIComponent(auditDirectMatch[1]);
330
+ } catch {
331
+ sessionId = auditDirectMatch[1];
332
+ }
333
+ handleDirectSessionAudit(res, sessionId);
334
+ return;
335
+ }
336
+ // POST /api/direct-sessions/:sessionId/hide — hide a direct session from the list
337
+ const hideDirectMatch = pathname.match(/^\/api\/direct-sessions\/([^/]+)\/hide$/);
338
+ if (hideDirectMatch) {
339
+ let sessionId: string;
340
+ try {
341
+ sessionId = decodeURIComponent(hideDirectMatch[1]);
342
+ } catch {
343
+ sessionId = hideDirectMatch[1];
344
+ }
345
+ handleDirectSessionHide(res, sessionId);
346
+ return;
347
+ }
348
+ // POST /api/tasks/:taskId/reconcile (token already validated above)
349
+ const reconcileMatch = pathname.match(/^\/api\/tasks\/([^/]+)\/reconcile$/);
350
+ if (reconcileMatch) {
351
+ let taskId: string;
352
+ try {
353
+ taskId = decodeURIComponent(reconcileMatch[1]);
354
+ } catch {
355
+ taskId = reconcileMatch[1];
356
+ }
357
+ handleReconcile(res, taskId);
358
+ return;
359
+ }
360
+ // POST /api/tasks/:taskId/audit — run audit_task safely
361
+ const auditMatch = pathname.match(/^\/api\/tasks\/([^/]+)\/audit$/);
362
+ if (auditMatch) {
363
+ let taskId: string;
364
+ try {
365
+ taskId = decodeURIComponent(auditMatch[1]);
366
+ } catch {
367
+ taskId = auditMatch[1];
368
+ }
369
+ handleTaskAudit(res, taskId);
370
+ return;
371
+ }
372
+ // POST /api/tasks/:taskId/open-folder — open task folder in file explorer
373
+ const openFolderMatch = pathname.match(/^\/api\/tasks\/([^/]+)\/open-folder$/);
374
+ if (openFolderMatch) {
375
+ let taskId: string;
376
+ try {
377
+ taskId = decodeURIComponent(openFolderMatch[1]);
378
+ } catch {
379
+ taskId = openFolderMatch[1];
380
+ }
381
+ handleOpenTaskFolder(res, taskId);
382
+ return;
383
+ }
384
+ // POST /api/tasks/:taskId/hide-stale — hide a stale task from the dashboard
385
+ const hideStaleMatch = pathname.match(/^\/api\/tasks\/([^/]+)\/hide-stale$/);
386
+ if (hideStaleMatch) {
387
+ let taskId: string;
388
+ try {
389
+ taskId = decodeURIComponent(hideStaleMatch[1]);
390
+ } catch {
391
+ taskId = hideStaleMatch[1];
392
+ }
393
+ handleHideStale(res, taskId);
394
+ return;
395
+ }
396
+ // POST /api/evidence-packs/:lineageId/export — export an evidence pack for a lineage
397
+ const exportPackMatch = pathname.match(/^\/api\/evidence-packs\/([^/]+)\/export$/);
398
+ if (exportPackMatch) {
399
+ let lineageId: string;
400
+ try {
401
+ lineageId = decodeURIComponent(exportPackMatch[1]);
402
+ } catch {
403
+ lineageId = exportPackMatch[1];
404
+ }
405
+ handleEvidencePackExport(res, lineageId);
406
+ return;
407
+ }
408
+ sendJson(res, 404, { error: "Not found" });
409
+ return;
410
+ }
411
+
412
+ sendJson(res, 404, { error: "Not found" });
413
+ }
414
+
415
+ // ── Server bootstrap ──────────────────────────────────────────────
416
+
417
+ let server: Server | null = null;
418
+
419
+ export function startServer(): Server {
420
+ server = createServer((req, res) => {
421
+ handleRequest(req, res).catch((err) => {
422
+ if (!res.headersSent) {
423
+ sendJson(res, 500, { error: errorMessage(err) });
424
+ } else {
425
+ try { res.end(); } catch { /* ignore */ }
426
+ }
427
+ });
428
+ });
429
+
430
+ server.on("error", (err) => {
431
+ logger.fatal("[control-center] Server error", { error: errorMessage(err) });
432
+ process.exit(1);
433
+ });
434
+
435
+ server.listen(port, host, () => {
436
+ const addr = server!.address();
437
+ const formatted = addr && typeof addr === "object" ? `http://${addr.address}:${addr.port}/` : `http://${host}:${port}/`;
438
+ logger.info(`[control-center] PatchWarden v${PATCHWARDEN_VERSION} (schema epoch ${TOOL_SCHEMA_EPOCH})`);
439
+ logger.info(`[control-center] Workspace: ${config.workspaceRoot}`);
440
+ logger.info(`[control-center] Listening: ${formatted}`);
441
+ logger.info(`[control-center] Bound to 127.0.0.1 only — not exposed to network`);
442
+ // Persist status file so the launcher can detect a running instance and
443
+ // open the browser without spawning a second server.
444
+ writeStatusFile();
445
+ recordEvent("control_center.started", {
446
+ pid: process.pid,
447
+ port,
448
+ url: formatted,
449
+ version: PATCHWARDEN_VERSION,
450
+ });
451
+ });
452
+
453
+ process.on("SIGINT", shutdown);
454
+ process.on("SIGTERM", shutdown);
455
+
456
+ return server;
457
+ }
458
+
459
+ function shutdown(): void {
460
+ logger.info("[control-center] Shutting down...");
461
+ recordEvent("control_center.stopped", { pid: process.pid });
462
+ removeStatusFile();
463
+ if (server) {
464
+ server.close(() => {
465
+ try { process.exit(0); } catch { /* ignore */ }
466
+ });
467
+ }
468
+ setTimeout(() => {
469
+ try { process.exit(0); } catch { /* ignore */ }
470
+ }, 3000).unref();
471
+ }