patchwarden 1.1.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 (258) hide show
  1. package/README.en.md +83 -8
  2. package/README.md +78 -10
  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 -2001
  42. package/dist/direct/directGuards.js +30 -8
  43. package/dist/direct/directSessionStore.d.ts +2 -0
  44. package/dist/direct/directVerification.js +7 -0
  45. package/dist/doctor.d.ts +18 -1
  46. package/dist/doctor.js +579 -348
  47. package/dist/goal/goalReport.d.ts +54 -0
  48. package/dist/goal/goalReport.js +204 -0
  49. package/dist/goal/goalStatus.d.ts +6 -0
  50. package/dist/goal/specKitImport.d.ts +63 -0
  51. package/dist/goal/specKitImport.js +220 -0
  52. package/dist/goal/subgoalSync.js +2 -1
  53. package/dist/httpServer.js +15 -12
  54. package/dist/index.js +7 -4
  55. package/dist/logging.d.ts +7 -1
  56. package/dist/logging.js +8 -0
  57. package/dist/policy/projectPolicy.d.ts +55 -0
  58. package/dist/policy/projectPolicy.js +286 -0
  59. package/dist/runner/changeCapture.d.ts +3 -3
  60. package/dist/runner/changeCapture.js +63 -39
  61. package/dist/runner/cli.js +7 -6
  62. package/dist/runner/postTaskCleanup.js +26 -2
  63. package/dist/runner/runTask.js +245 -221
  64. package/dist/runner/simpleProcess.js +4 -4
  65. package/dist/runner/watch.js +17 -14
  66. package/dist/security/contentRedaction.d.ts +6 -0
  67. package/dist/security/contentRedaction.js +22 -0
  68. package/dist/smoke-test.js +261 -255
  69. package/dist/test/unit/apply-patch.test.d.ts +1 -0
  70. package/dist/test/unit/apply-patch.test.js +225 -0
  71. package/dist/test/unit/create-task.test.d.ts +1 -0
  72. package/dist/test/unit/create-task.test.js +197 -0
  73. package/dist/test/unit/direct-guards.test.js +124 -9
  74. package/dist/test/unit/evidence-pack.test.d.ts +1 -0
  75. package/dist/test/unit/evidence-pack.test.js +224 -0
  76. package/dist/test/unit/get-task-status.test.d.ts +1 -0
  77. package/dist/test/unit/get-task-status.test.js +174 -0
  78. package/dist/test/unit/get-task-summary.test.d.ts +1 -0
  79. package/dist/test/unit/get-task-summary.test.js +146 -0
  80. package/dist/test/unit/goal-report.test.d.ts +1 -0
  81. package/dist/test/unit/goal-report.test.js +159 -0
  82. package/dist/test/unit/goal-subgoal-task.test.js +6 -6
  83. package/dist/test/unit/goal-tools-registry.test.js +6 -4
  84. package/dist/test/unit/path-guard.test.js +24 -0
  85. package/dist/test/unit/project-policy-release-mode.test.d.ts +1 -0
  86. package/dist/test/unit/project-policy-release-mode.test.js +125 -0
  87. package/dist/test/unit/run-task-loop.test.d.ts +1 -0
  88. package/dist/test/unit/run-task-loop.test.js +380 -0
  89. package/dist/test/unit/schema-drift-check.test.js +10 -9
  90. package/dist/test/unit/spec-kit-import.test.d.ts +1 -0
  91. package/dist/test/unit/spec-kit-import.test.js +341 -0
  92. package/dist/test/unit/wait-for-task.test.d.ts +1 -0
  93. package/dist/test/unit/wait-for-task.test.js +144 -0
  94. package/dist/tools/auditTask.d.ts +8 -63
  95. package/dist/tools/auditTask.js +12 -8
  96. package/dist/tools/createDirectSession.d.ts +1 -1
  97. package/dist/tools/createDirectSession.js +2 -2
  98. package/dist/tools/createTask.d.ts +2 -2
  99. package/dist/tools/createTask.js +4 -4
  100. package/dist/tools/dispatch/coreDispatch.d.ts +9 -0
  101. package/dist/tools/dispatch/coreDispatch.js +282 -0
  102. package/dist/tools/dispatch/diagnosticDispatch.d.ts +14 -0
  103. package/dist/tools/dispatch/diagnosticDispatch.js +78 -0
  104. package/dist/tools/dispatch/directDispatch.d.ts +8 -0
  105. package/dist/tools/dispatch/directDispatch.js +115 -0
  106. package/dist/tools/dispatch/goalDispatch.d.ts +8 -0
  107. package/dist/tools/dispatch/goalDispatch.js +91 -0
  108. package/dist/tools/dispatch/releaseDispatch.d.ts +8 -0
  109. package/dist/tools/dispatch/releaseDispatch.js +45 -0
  110. package/dist/tools/dispatch/types.d.ts +23 -0
  111. package/dist/tools/dispatch/types.js +15 -0
  112. package/dist/tools/evidencePack.d.ts +45 -0
  113. package/dist/tools/evidencePack.js +375 -0
  114. package/dist/tools/finalizeDirectSession.d.ts +1 -1
  115. package/dist/tools/finalizeDirectSession.js +3 -3
  116. package/dist/tools/goalSubgoalTask.d.ts +1 -1
  117. package/dist/tools/goalSubgoalTask.js +2 -2
  118. package/dist/tools/healthCheck.js +3 -3
  119. package/dist/tools/recommendAgentForTask.d.ts +19 -0
  120. package/dist/tools/recommendAgentForTask.js +56 -0
  121. package/dist/tools/registry.d.ts +3 -3
  122. package/dist/tools/registry.js +333 -402
  123. package/dist/tools/releaseMode.d.ts +50 -0
  124. package/dist/tools/releaseMode.js +370 -0
  125. package/dist/tools/retryTask.d.ts +2 -2
  126. package/dist/tools/retryTask.js +2 -2
  127. package/dist/tools/runDirectVerificationBundle.d.ts +26 -0
  128. package/dist/tools/runDirectVerificationBundle.js +64 -0
  129. package/dist/tools/runTaskLoop.d.ts +57 -0
  130. package/dist/tools/runTaskLoop.js +417 -0
  131. package/dist/tools/runVerification.d.ts +4 -0
  132. package/dist/tools/runVerification.js +4 -0
  133. package/dist/tools/safeViews.d.ts +8 -2
  134. package/dist/tools/safeViews.js +4 -2
  135. package/dist/tools/taskLineage.d.ts +91 -0
  136. package/dist/tools/taskLineage.js +175 -0
  137. package/dist/tools/toolCatalog.d.ts +2 -2
  138. package/dist/tools/toolCatalog.js +6 -0
  139. package/dist/tools/toolRegistry.js +132 -0
  140. package/dist/version.d.ts +2 -2
  141. package/dist/version.js +2 -2
  142. package/docs/CODE_WIKI.md +983 -0
  143. package/docs/agentseal-integration.md +150 -0
  144. package/docs/architecture.md +63 -0
  145. package/docs/assets/patchwarden-oss-demo.gif +0 -0
  146. package/docs/chatgpt-usage.md +31 -0
  147. package/docs/control-center/README.md +9 -0
  148. package/docs/dashboard-overview.md +86 -0
  149. package/docs/demo.md +8 -0
  150. package/docs/direct-session-workflow.md +98 -0
  151. package/docs/evidence-pack-schema.md +215 -0
  152. package/docs/execution-plan-2026-07-09.md +315 -0
  153. package/docs/lineage-evidence-pack-workflow.md +127 -0
  154. package/docs/mcp-inspector-testing.md +200 -0
  155. package/docs/open-source-application.md +162 -0
  156. package/docs/opencode-worker.md +151 -0
  157. package/docs/openhands-worker.md +181 -0
  158. package/docs/release-evidence.md +72 -0
  159. package/docs/roadmap-execution-and-acceptance.md +365 -0
  160. package/docs/spec-kit-integration.md +131 -0
  161. package/docs/task-safe-review-workflow.md +98 -0
  162. package/docs/threat-model.md +79 -0
  163. package/docs/user-feedback.md +40 -0
  164. package/docs/why-patchwarden.md +110 -0
  165. package/package.json +3 -3
  166. package/scripts/checks/control-center-smoke.js +443 -0
  167. package/scripts/checks/control-smoke.js +2 -2
  168. package/scripts/checks/lifecycle-smoke.js +29 -23
  169. package/scripts/checks/mcp-manifest-check.js +12 -0
  170. package/scripts/checks/mcp-smoke.js +33 -7
  171. package/scripts/checks/watcher-supervisor-smoke.js +1 -1
  172. package/scripts/generate-demo-gif.py +320 -0
  173. package/src/assessments/agentAssessor.ts +3 -3
  174. package/src/assessments/assessmentStore.ts +2 -2
  175. package/src/assessments/confirmCli.ts +5 -4
  176. package/src/config.ts +37 -0
  177. package/src/control/middleware/auth.ts +17 -0
  178. package/src/control/middleware/static.ts +71 -0
  179. package/src/control/routes/audit.ts +321 -0
  180. package/src/control/routes/evidence.ts +107 -0
  181. package/src/control/routes/lineage.ts +92 -0
  182. package/src/control/routes/policy.ts +81 -0
  183. package/src/control/routes/process.ts +204 -0
  184. package/src/control/routes/sessions.ts +251 -0
  185. package/src/control/routes/status.ts +325 -0
  186. package/src/control/routes/taskActions.ts +248 -0
  187. package/src/control/routes/tasks.ts +323 -0
  188. package/src/control/routes/workspace.ts +203 -0
  189. package/src/control/runtime.ts +472 -0
  190. package/src/control/server.ts +471 -0
  191. package/src/control/shared.ts +294 -0
  192. package/src/controlCenter.ts +7 -2150
  193. package/src/direct/directGuards.ts +28 -7
  194. package/src/direct/directSessionStore.ts +2 -0
  195. package/src/direct/directVerification.ts +7 -0
  196. package/src/doctor.ts +741 -481
  197. package/src/goal/goalReport.ts +271 -0
  198. package/src/goal/goalStatus.ts +6 -0
  199. package/src/goal/specKitImport.ts +355 -0
  200. package/src/goal/subgoalSync.ts +4 -2
  201. package/src/httpServer.ts +17 -14
  202. package/src/index.ts +7 -4
  203. package/src/logging.ts +10 -1
  204. package/src/policy/projectPolicy.ts +344 -0
  205. package/src/runner/changeCapture.ts +70 -42
  206. package/src/runner/cli.ts +7 -6
  207. package/src/runner/postTaskCleanup.ts +26 -2
  208. package/src/runner/runTask.ts +325 -223
  209. package/src/runner/simpleProcess.ts +4 -4
  210. package/src/runner/watch.ts +17 -14
  211. package/src/security/contentRedaction.ts +29 -0
  212. package/src/smoke-test.ts +254 -252
  213. package/src/test/unit/apply-patch.test.ts +293 -0
  214. package/src/test/unit/create-task.test.ts +255 -0
  215. package/src/test/unit/direct-guards.test.ts +178 -8
  216. package/src/test/unit/evidence-pack.test.ts +251 -0
  217. package/src/test/unit/get-task-status.test.ts +203 -0
  218. package/src/test/unit/get-task-summary.test.ts +173 -0
  219. package/src/test/unit/goal-report.test.ts +189 -0
  220. package/src/test/unit/goal-subgoal-task.test.ts +6 -6
  221. package/src/test/unit/goal-tools-registry.test.ts +7 -5
  222. package/src/test/unit/path-guard.test.ts +30 -0
  223. package/src/test/unit/project-policy-release-mode.test.ts +156 -0
  224. package/src/test/unit/run-task-loop.test.ts +425 -0
  225. package/src/test/unit/schema-drift-check.test.ts +11 -9
  226. package/src/test/unit/spec-kit-import.test.ts +429 -0
  227. package/src/test/unit/wait-for-task.test.ts +176 -0
  228. package/src/tools/auditTask.ts +99 -59
  229. package/src/tools/createDirectSession.ts +3 -3
  230. package/src/tools/createTask.ts +7 -7
  231. package/src/tools/dispatch/coreDispatch.ts +374 -0
  232. package/src/tools/dispatch/diagnosticDispatch.ts +101 -0
  233. package/src/tools/dispatch/directDispatch.ts +167 -0
  234. package/src/tools/dispatch/goalDispatch.ts +127 -0
  235. package/src/tools/dispatch/releaseDispatch.ts +65 -0
  236. package/src/tools/dispatch/types.ts +24 -0
  237. package/src/tools/evidencePack.ts +490 -0
  238. package/src/tools/finalizeDirectSession.ts +4 -4
  239. package/src/tools/goalSubgoalTask.ts +2 -2
  240. package/src/tools/healthCheck.ts +3 -3
  241. package/src/tools/listWorkspace.ts +71 -71
  242. package/src/tools/recommendAgentForTask.ts +79 -0
  243. package/src/tools/registry.ts +352 -509
  244. package/src/tools/releaseMode.ts +450 -0
  245. package/src/tools/retryTask.ts +2 -2
  246. package/src/tools/runDirectVerificationBundle.ts +98 -0
  247. package/src/tools/runTaskLoop.ts +526 -0
  248. package/src/tools/runVerification.ts +8 -0
  249. package/src/tools/safeViews.ts +4 -2
  250. package/src/tools/taskLineage.ts +300 -0
  251. package/src/tools/toolCatalog.ts +6 -0
  252. package/src/tools/toolRegistry.ts +132 -0
  253. package/src/version.ts +2 -2
  254. package/ui/pages/audit.html +192 -3
  255. package/ui/pages/dashboard.html +1070 -14
  256. package/ui/pages/direct-sessions.html +505 -53
  257. package/ui/pages/task-detail.html +456 -438
  258. package/ui/pages/tasks.html +598 -61
@@ -21,7 +21,7 @@ export interface AgentAssessorInput {
21
21
  config: PatchWardenConfig;
22
22
  }
23
23
 
24
- export function runAgentAssessment(input: AgentAssessorInput): AgentAssessmentSummary {
24
+ export async function runAgentAssessment(input: AgentAssessorInput): Promise<AgentAssessmentSummary> {
25
25
  const logPaths: AgentAssessmentSummary["log_paths"] = {
26
26
  stdout: resolve(input.assessmentDir, "agent-assessment-stdout.log"),
27
27
  stderr: resolve(input.assessmentDir, "agent-assessment-stderr.log"),
@@ -53,7 +53,7 @@ export function runAgentAssessment(input: AgentAssessorInput): AgentAssessmentSu
53
53
  // ── 2. Before snapshot (repo-scoped) ──
54
54
  let repoBefore: RepoSnapshot;
55
55
  try {
56
- repoBefore = captureRepoSnapshot(input.repoPath);
56
+ repoBefore = await captureRepoSnapshot(input.repoPath);
57
57
  } catch {
58
58
  // If we can't capture before snapshot, conservatively skip agent assessment
59
59
  emptySummary.status = "spawn_failed";
@@ -95,7 +95,7 @@ export function runAgentAssessment(input: AgentAssessorInput): AgentAssessmentSu
95
95
  let readOnlyViolation = false;
96
96
  let violationFiles: string[] = [];
97
97
  try {
98
- const repoAfter = captureRepoSnapshot(input.repoPath);
98
+ const repoAfter = await captureRepoSnapshot(input.repoPath);
99
99
  const changes = compareSnapshots(repoBefore, repoAfter);
100
100
  if (changes.length > 0) {
101
101
  readOnlyViolation = true;
@@ -314,7 +314,7 @@ export function validateAssessmentFreshness(
314
314
  * This is intentionally not registered as an MCP tool: a remote client may
315
315
  * request confirmation, but it cannot grant confirmation to itself.
316
316
  */
317
- export function confirmAssessment(assessmentId: string): AssessmentConfirmationResult {
317
+ export async function confirmAssessment(assessmentId: string): Promise<AssessmentConfirmationResult> {
318
318
  if (!/^assessment_\d{8}_\d{6}_[0-9a-f]{32}$/.test(assessmentId)) {
319
319
  throw new PatchWardenError(
320
320
  "assessment_id_invalid",
@@ -334,7 +334,7 @@ export function confirmAssessment(assessmentId: string): AssessmentConfirmationR
334
334
  );
335
335
  }
336
336
 
337
- const snapshot = captureRepoSnapshot(assessment.resolved_repo_path);
337
+ const snapshot = await captureRepoSnapshot(assessment.resolved_repo_path);
338
338
  const validation = validateAssessmentFreshness(assessmentId, snapshot, {
339
339
  allow_unconfirmed: true,
340
340
  // A standalone CLI has no active MCP profile snapshot. The execute path
@@ -10,20 +10,21 @@
10
10
  import { loadConfig } from "../config.js";
11
11
  import { confirmAssessment } from "./assessmentStore.js";
12
12
  import { errorPayload } from "../errors.js";
13
+ import { logger } from "../logging.js";
13
14
 
14
15
  loadConfig();
15
16
 
16
17
  const assessmentId = process.argv[2];
17
18
  if (!assessmentId || process.argv.length !== 3) {
18
- console.error("Usage: patchwarden-confirm <full_assessment_id>");
19
- console.error("The display-only assessment_short_id is not accepted.");
19
+ logger.info("Usage: patchwarden-confirm <full_assessment_id>");
20
+ logger.info("The display-only assessment_short_id is not accepted.");
20
21
  process.exit(1);
21
22
  }
22
23
 
23
24
  try {
24
- const result = confirmAssessment(assessmentId);
25
+ const result = await confirmAssessment(assessmentId);
25
26
  console.log(JSON.stringify(result, null, 2));
26
27
  } catch (error) {
27
- console.error(JSON.stringify(errorPayload(error), null, 2));
28
+ logger.error("confirm_assessment_failed", { error: errorPayload(error) });
28
29
  process.exit(1);
29
30
  }
package/src/config.ts CHANGED
@@ -23,6 +23,9 @@ export interface PatchWardenConfig {
23
23
  watcherStaleSeconds: number;
24
24
  toolProfile?: "full" | "chatgpt_core" | "chatgpt_direct" | "chatgpt_search";
25
25
  repoAliases?: Record<string, string>;
26
+ httpPort?: number;
27
+ http?: { port?: number; host?: string; ownerTokenEnv?: string };
28
+ enableRunTaskTool?: boolean;
26
29
  enableAgentAssessment?: boolean;
27
30
  agentAssessmentTimeoutSeconds?: number;
28
31
  agentAssessmentMaxOutputBytes?: number;
@@ -233,6 +236,40 @@ function normalizeConfig(config: PatchWardenConfig): PatchWardenConfig {
233
236
  ) {
234
237
  throw new Error('toolProfile must be "full", "chatgpt_core", "chatgpt_direct", or "chatgpt_search"');
235
238
  }
239
+ if (config.repoAliases !== undefined) {
240
+ if (typeof config.repoAliases !== "object" || config.repoAliases === null || Array.isArray(config.repoAliases)) {
241
+ throw new Error("repoAliases must be an object mapping alias names to repository paths");
242
+ }
243
+ for (const [alias, target] of Object.entries(config.repoAliases)) {
244
+ if (typeof target !== "string") {
245
+ throw new Error(`repoAliases["${alias}"] must be a string`);
246
+ }
247
+ }
248
+ }
249
+ if (config.httpPort !== undefined) {
250
+ if (typeof config.httpPort !== "number" || !Number.isInteger(config.httpPort) || config.httpPort < 1 || config.httpPort > 65535) {
251
+ throw new Error("httpPort must be an integer from 1 to 65535");
252
+ }
253
+ }
254
+ if (config.http !== undefined) {
255
+ if (typeof config.http !== "object" || config.http === null || Array.isArray(config.http)) {
256
+ throw new Error("http must be an object");
257
+ }
258
+ if (config.http.port !== undefined) {
259
+ if (typeof config.http.port !== "number" || !Number.isInteger(config.http.port) || config.http.port < 1 || config.http.port > 65535) {
260
+ throw new Error("http.port must be an integer from 1 to 65535");
261
+ }
262
+ }
263
+ if (config.http.host !== undefined && typeof config.http.host !== "string") {
264
+ throw new Error("http.host must be a string");
265
+ }
266
+ if (config.http.ownerTokenEnv !== undefined && typeof config.http.ownerTokenEnv !== "string") {
267
+ throw new Error("http.ownerTokenEnv must be a string");
268
+ }
269
+ }
270
+ if (config.enableRunTaskTool !== undefined && typeof config.enableRunTaskTool !== "boolean") {
271
+ throw new Error("enableRunTaskTool must be a boolean");
272
+ }
236
273
  if (config.enableAgentAssessment !== undefined && typeof config.enableAgentAssessment !== "boolean") {
237
274
  throw new Error("enableAgentAssessment must be a boolean");
238
275
  }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Control Center auth middleware — control token validation.
3
+ *
4
+ * POST routes are gated behind an in-memory control token generated at boot.
5
+ * The token is exposed read-only via GET /control-token.json (handled by the
6
+ * server router) so the local dashboard can read it, but every mutating
7
+ * action must pass through `checkControlToken` first.
8
+ */
9
+ import { type IncomingMessage } from "node:http";
10
+ import { controlToken } from "../shared.js";
11
+
12
+ export function checkControlToken(req: IncomingMessage): boolean {
13
+ const header = req.headers["x-patchwarden-control-token"];
14
+ const provided = Array.isArray(header) ? header[0] : header;
15
+ if (typeof provided !== "string" || provided.length === 0) return false;
16
+ return provided === controlToken;
17
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Control Center static file middleware.
3
+ *
4
+ * Serves the bundled `ui/` directory (dashboard pages, partials, vendor
5
+ * assets, css) with path-traversal protection, plus the inline SVG favicon.
6
+ * Content types are mapped from `CONTENT_TYPES` in shared.ts.
7
+ */
8
+ import { existsSync, readFileSync, statSync } from "node:fs";
9
+ import { extname, isAbsolute, relative, resolve } from "node:path";
10
+ import { type ServerResponse } from "node:http";
11
+ import {
12
+ CONTENT_TYPES,
13
+ CONTROL_CENTER_FAVICON,
14
+ errorMessage,
15
+ sendJson,
16
+ uiRoot,
17
+ } from "../shared.js";
18
+
19
+ export function serveStatic(res: ServerResponse, urlPath: string): void {
20
+ let candidate = "";
21
+ try {
22
+ const normalized = urlPath.startsWith("/") ? urlPath.slice(1) : urlPath;
23
+ let decoded: string;
24
+ try {
25
+ decoded = decodeURIComponent(normalized);
26
+ } catch {
27
+ sendJson(res, 400, { error: "Invalid path encoding" });
28
+ return;
29
+ }
30
+ if (decoded.includes("\0")) {
31
+ sendJson(res, 400, { error: "Invalid path" });
32
+ return;
33
+ }
34
+ const segments = decoded.split("/").filter(Boolean);
35
+ if (segments.some((s) => s === "..")) {
36
+ sendJson(res, 403, { error: "Forbidden" });
37
+ return;
38
+ }
39
+ candidate = resolve(uiRoot, ...segments);
40
+ const rel = relative(uiRoot, candidate);
41
+ if (rel === "" || rel.startsWith("..") || isAbsolute(rel)) {
42
+ sendJson(res, 403, { error: "Forbidden" });
43
+ return;
44
+ }
45
+ } catch (err) {
46
+ sendJson(res, 500, { error: errorMessage(err) });
47
+ return;
48
+ }
49
+ try {
50
+ if (!existsSync(candidate) || !statSync(candidate).isFile()) {
51
+ sendJson(res, 404, { error: "Not found" });
52
+ return;
53
+ }
54
+ const ext = extname(candidate).toLowerCase();
55
+ const contentType = CONTENT_TYPES[ext] || "application/octet-stream";
56
+ const content = readFileSync(candidate);
57
+ res.writeHead(200, { "Content-Type": contentType });
58
+ res.end(content);
59
+ } catch (err) {
60
+ sendJson(res, 500, { error: errorMessage(err) });
61
+ }
62
+ }
63
+
64
+ export function serveFavicon(res: ServerResponse): void {
65
+ res.writeHead(200, {
66
+ "Content-Type": "image/svg+xml; charset=utf-8",
67
+ "Cache-Control": "public, max-age=86400",
68
+ "X-Content-Type-Options": "nosniff",
69
+ });
70
+ res.end(CONTROL_CENTER_FAVICON);
71
+ }
@@ -0,0 +1,321 @@
1
+ /**
2
+ * Control Center routes — audit aggregation, warnings, and log tails.
3
+ *
4
+ * `handleAudit` joins independent-review.md + audit.json across tasks and
5
+ * direct sessions. `handleWarnings` buckets audit/stale/verification warnings
6
+ * by type. `handleLogs` returns redacted stdout/stderr tails for core, direct,
7
+ * watcher, and control-center log categories.
8
+ */
9
+ import { existsSync, readdirSync } from "node:fs";
10
+ import { join } from "node:path";
11
+ import { type ServerResponse } from "node:http";
12
+ import { type TaskEntry, listTasks } from "../../tools/listTasks.js";
13
+ import { type WatcherStatusSnapshot } from "../../watcherStatus.js";
14
+ import { redactSensitiveContent } from "../../security/contentRedaction.js";
15
+ import { getDirectSessionsDir, getTasksDir } from "../../config.js";
16
+ import {
17
+ classifyStaleTask,
18
+ fileMtimeIso,
19
+ parseReviewVerdict,
20
+ readWatcherStatusSafe,
21
+ } from "../runtime.js";
22
+ import {
23
+ config,
24
+ errorMessage,
25
+ findLatestLog,
26
+ getControlCenterLogDir,
27
+ getRuntimeRoot,
28
+ readFileTail,
29
+ readJsonFileSafe,
30
+ readTextFileSafe,
31
+ sendJson,
32
+ } from "../shared.js";
33
+
34
+ export type LogCategory = "core" | "direct" | "watcher" | "control-center";
35
+
36
+ export function handleLogs(res: ServerResponse, category: LogCategory, tailLines: number): void {
37
+ try {
38
+ let dir: string;
39
+ let stdoutPath: string;
40
+ let stderrPath: string;
41
+ let stdoutExists: boolean;
42
+ let stderrExists: boolean;
43
+
44
+ if (category === "control-center") {
45
+ dir = getControlCenterLogDir();
46
+ stdoutPath = join(dir, "control-center.stdout.log");
47
+ stderrPath = join(dir, "control-center.stderr.log");
48
+ stdoutExists = existsSync(stdoutPath);
49
+ stderrExists = existsSync(stderrPath);
50
+ } else if (category === "watcher") {
51
+ dir = getRuntimeRoot(false);
52
+ const sp = findLatestLog(dir, /^watcher-.*\.stdout\.log$/);
53
+ const ep = findLatestLog(dir, /^watcher-.*\.stderr\.log$/);
54
+ stdoutPath = sp ?? "";
55
+ stderrPath = ep ?? "";
56
+ stdoutExists = sp !== null;
57
+ stderrExists = ep !== null;
58
+ } else {
59
+ // core | direct -> tunnel client logs in the matching runtime dir
60
+ dir = getRuntimeRoot(category === "direct");
61
+ stdoutPath = join(dir, "tunnel-client.stdout.log");
62
+ stderrPath = join(dir, "tunnel-client.stderr.log");
63
+ stdoutExists = existsSync(stdoutPath);
64
+ stderrExists = existsSync(stderrPath);
65
+ }
66
+
67
+ if (!stdoutExists && !stderrExists) {
68
+ sendJson(res, 200, {
69
+ stdout: "",
70
+ stderr: "",
71
+ category,
72
+ tail: tailLines,
73
+ reason: "log file not found",
74
+ });
75
+ return;
76
+ }
77
+
78
+ const stdoutRaw = stdoutExists ? readFileTail(stdoutPath, tailLines) : "";
79
+ const stderrRaw = stderrExists ? readFileTail(stderrPath, tailLines) : "";
80
+ const stdout = redactSensitiveContent(stdoutRaw).content;
81
+ const stderr = redactSensitiveContent(stderrRaw).content;
82
+ sendJson(res, 200, { stdout, stderr, category, tail: tailLines, reason: null });
83
+ } catch (err) {
84
+ sendJson(res, 200, { stdout: "", stderr: "", category, tail: tailLines, reason: errorMessage(err) });
85
+ }
86
+ }
87
+
88
+ export function handleAudit(res: ServerResponse): void {
89
+ try {
90
+ const audits: Array<Record<string, unknown>> = [];
91
+
92
+ // 1. tasks/*/independent-review.md (written by audit_task — the primary audit artifact)
93
+ // 2. tasks/*/audit.json (legacy/explicit JSON audit, if present)
94
+ const tasksDir = getTasksDir(config);
95
+ if (existsSync(tasksDir)) {
96
+ let taskEntries: import("node:fs").Dirent[] = [];
97
+ try {
98
+ taskEntries = readdirSync(tasksDir, { withFileTypes: true }).filter((e) => e.isDirectory());
99
+ } catch {
100
+ taskEntries = [];
101
+ }
102
+ for (const entry of taskEntries) {
103
+ const taskDir = join(tasksDir, entry.name);
104
+
105
+ // independent-review.md
106
+ const reviewFile = join(taskDir, "independent-review.md");
107
+ if (existsSync(reviewFile)) {
108
+ const content = readTextFileSafe(reviewFile) ?? "";
109
+ audits.push({
110
+ task_id: entry.name,
111
+ source: "independent-review.md",
112
+ verdict: parseReviewVerdict(content),
113
+ checked_at: fileMtimeIso(reviewFile),
114
+ content_excerpt: content.slice(0, 500),
115
+ });
116
+ }
117
+
118
+ // audit.json (explicit JSON audit if present)
119
+ const auditFile = join(taskDir, "audit.json");
120
+ if (existsSync(auditFile)) {
121
+ const data = readJsonFileSafe<Record<string, unknown>>(auditFile);
122
+ if (data) {
123
+ audits.push({
124
+ task_id: entry.name,
125
+ source: "audit.json",
126
+ checked_at: data.checked_at ?? fileMtimeIso(auditFile),
127
+ ...data,
128
+ });
129
+ }
130
+ }
131
+ }
132
+ }
133
+
134
+ // 3. direct-sessions/*/audit.json (written by Direct audit_session)
135
+ const sessionsDir = getDirectSessionsDir(config);
136
+ if (existsSync(sessionsDir)) {
137
+ let sessionEntries: import("node:fs").Dirent[] = [];
138
+ try {
139
+ sessionEntries = readdirSync(sessionsDir, { withFileTypes: true }).filter((e) => e.isDirectory());
140
+ } catch {
141
+ sessionEntries = [];
142
+ }
143
+ for (const entry of sessionEntries) {
144
+ const auditFile = join(sessionsDir, entry.name, "audit.json");
145
+ if (!existsSync(auditFile)) continue;
146
+ const data = readJsonFileSafe<Record<string, unknown>>(auditFile);
147
+ if (data) {
148
+ audits.push({
149
+ source: "direct-session",
150
+ session_id: data.session_id ?? entry.name,
151
+ checked_at: fileMtimeIso(auditFile),
152
+ ...data,
153
+ });
154
+ }
155
+ }
156
+ }
157
+
158
+ // Sort by checked_at descending (missing timestamps sort last).
159
+ audits.sort((a, b) => {
160
+ const ac = String(a.checked_at ?? "");
161
+ const bc = String(b.checked_at ?? "");
162
+ return bc.localeCompare(ac);
163
+ });
164
+ const limited = audits.slice(0, 50);
165
+ sendJson(res, 200, { audits: limited, total: limited.length });
166
+ } catch (err) {
167
+ sendJson(res, 200, { audits: [], reason: errorMessage(err) });
168
+ }
169
+ }
170
+
171
+ // ── Warnings aggregation ─────────────────────────────────────────
172
+
173
+ interface WarningEntry {
174
+ type: string;
175
+ severity: "error" | "warning" | "info";
176
+ affected_tasks_count: number;
177
+ affected_tasks: string[];
178
+ likely_false_positive: boolean;
179
+ needs_fix: boolean;
180
+ blocked: boolean;
181
+ recommended_action: string;
182
+ }
183
+
184
+ /**
185
+ * Aggregate warnings from multiple sources:
186
+ * - audit.json warnings/fail_checks/possible_false_positives/manual_verification_required
187
+ * - stale task classification (heartbeat stale, collecting_artifacts stale, etc.)
188
+ * - task status=failed_verification
189
+ *
190
+ * Each warning type is grouped into a bucket with affected task IDs. The
191
+ * handler is fault-tolerant: any per-task read failure is skipped, and a
192
+ * top-level failure returns an empty warnings array (never 500).
193
+ */
194
+ export function handleWarnings(res: ServerResponse): void {
195
+ try {
196
+ const buckets: Record<string, Set<string>> = {
197
+ unrecorded_command_execution: new Set(),
198
+ artifact_hygiene: new Set(),
199
+ scope_changes: new Set(),
200
+ release_publish_claim: new Set(),
201
+ manual_verification_required: new Set(),
202
+ stale_task: new Set(),
203
+ failed_verification: new Set(),
204
+ };
205
+
206
+ const tasksDir = getTasksDir(config);
207
+ let taskList: TaskEntry[] = [];
208
+ let watcher: WatcherStatusSnapshot;
209
+ try {
210
+ const result = listTasks({ limit: 100 });
211
+ taskList = result.tasks;
212
+ watcher = result.watcher;
213
+ } catch (err) {
214
+ watcher = readWatcherStatusSafe();
215
+ }
216
+
217
+ const now = Date.now();
218
+
219
+ for (const task of taskList) {
220
+ const taskId = task.task_id;
221
+
222
+ // 1. Read audit.json for warning strings
223
+ const auditFile = join(tasksDir, taskId, "audit.json");
224
+ const audit = readJsonFileSafe<Record<string, unknown>>(auditFile);
225
+ if (audit) {
226
+ const warningTexts: string[] = [];
227
+ const collectStrings = (field: unknown) => {
228
+ if (Array.isArray(field)) {
229
+ for (const w of field) {
230
+ if (typeof w === "string") {
231
+ warningTexts.push(w);
232
+ } else if (w && typeof w === "object") {
233
+ const obj = w as Record<string, unknown>;
234
+ if (typeof obj.message === "string") warningTexts.push(obj.message);
235
+ else if (typeof obj.description === "string") warningTexts.push(obj.description);
236
+ else if (typeof obj.warning === "string") warningTexts.push(obj.warning);
237
+ else warningTexts.push(JSON.stringify(obj));
238
+ }
239
+ }
240
+ } else if (typeof field === "string") {
241
+ warningTexts.push(field);
242
+ }
243
+ };
244
+ collectStrings(audit.warnings);
245
+ collectStrings(audit.fail_checks);
246
+ collectStrings(audit.possible_false_positives);
247
+
248
+ for (const text of warningTexts) {
249
+ const lower = text.toLowerCase();
250
+ if (lower.includes("unrecorded") || lower.includes("command execution")) {
251
+ buckets.unrecorded_command_execution.add(taskId);
252
+ }
253
+ if (lower.includes("artifact") || lower.includes("hygiene")) {
254
+ buckets.artifact_hygiene.add(taskId);
255
+ }
256
+ if (lower.includes("scope") || lower.includes("out_of_scope")) {
257
+ buckets.scope_changes.add(taskId);
258
+ }
259
+ if (lower.includes("release") || lower.includes("publish")) {
260
+ buckets.release_publish_claim.add(taskId);
261
+ }
262
+ }
263
+
264
+ // manual_verification_required flag
265
+ if (audit.manual_verification_required === true) {
266
+ buckets.manual_verification_required.add(taskId);
267
+ }
268
+ }
269
+
270
+ // 2. Stale tasks
271
+ try {
272
+ const cls = classifyStaleTask(task, watcher, now);
273
+ if (cls.is_stale) {
274
+ buckets.stale_task.add(taskId);
275
+ }
276
+ } catch {
277
+ // skip stale classification failure
278
+ }
279
+
280
+ // 3. Failed verification
281
+ if (task.status === "failed_verification") {
282
+ buckets.failed_verification.add(taskId);
283
+ }
284
+ }
285
+
286
+ const warnings: WarningEntry[] = [];
287
+ const addWarning = (
288
+ type: string,
289
+ severity: "error" | "warning" | "info",
290
+ likelyFalsePositive: boolean,
291
+ needsFix: boolean,
292
+ blocked: boolean,
293
+ recommendedAction: string
294
+ ) => {
295
+ const tasks = buckets[type];
296
+ if (tasks.size === 0) return;
297
+ warnings.push({
298
+ type,
299
+ severity,
300
+ affected_tasks_count: tasks.size,
301
+ affected_tasks: Array.from(tasks),
302
+ likely_false_positive: likelyFalsePositive,
303
+ needs_fix: needsFix,
304
+ blocked,
305
+ recommended_action: recommendedAction,
306
+ });
307
+ };
308
+
309
+ addWarning("failed_verification", "error", false, true, false, "Re-run verification or fix the failing checks before retrying.");
310
+ addWarning("stale_task", "warning", false, false, false, "Reconcile or recreate stale tasks.");
311
+ addWarning("manual_verification_required", "warning", false, false, false, "Review the audit findings and manually verify the changes.");
312
+ addWarning("unrecorded_command_execution", "info", false, true, false, "Investigate unrecorded command executions; ensure watcher captures all commands.");
313
+ addWarning("release_publish_claim", "info", false, false, true, "Verify the release/publish claim against actual release artifacts before proceeding.");
314
+ addWarning("artifact_hygiene", "info", true, false, false, "Review artifact hygiene warnings; many are likely false positives.");
315
+ addWarning("scope_changes", "info", true, false, false, "Review scope changes; verify they are intentional before accepting.");
316
+
317
+ sendJson(res, 200, { warnings, total: warnings.length });
318
+ } catch (err) {
319
+ sendJson(res, 200, { warnings: [], total: 0, error: errorMessage(err) });
320
+ }
321
+ }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Control Center routes — evidence packs (/api/evidence-packs/*).
3
+ *
4
+ * Lists exported evidence packs (bounded to 50), reads a single pack detail,
5
+ * and exports a pack for a lineage on demand. The export endpoint is a POST
6
+ * route gated by the control token in the server router.
7
+ */
8
+ import { existsSync, readdirSync } from "node:fs";
9
+ import { join } from "node:path";
10
+ import { type ServerResponse } from "node:http";
11
+ import {
12
+ exportTaskEvidencePack,
13
+ listEvidencePacks,
14
+ readEvidencePack,
15
+ type SafeEvidencePack,
16
+ } from "../../tools/evidencePack.js";
17
+ import { errorMessage, sendJson, config } from "../shared.js";
18
+
19
+ function augmentEvidencePackSummary(pack: SafeEvidencePack): SafeEvidencePack & {
20
+ export_status: "exported" | "pending";
21
+ evidence_json_exists: boolean;
22
+ evidence_md_exists: boolean;
23
+ exported_at: string;
24
+ } {
25
+ const jsonExists = existsSync(pack.files.json);
26
+ const mdExists = existsSync(pack.files.markdown);
27
+ return {
28
+ ...pack,
29
+ export_status: jsonExists ? "exported" : "pending",
30
+ evidence_json_exists: jsonExists,
31
+ evidence_md_exists: mdExists,
32
+ exported_at: pack.generated_at,
33
+ };
34
+ }
35
+
36
+ export function handleEvidencePacks(res: ServerResponse): void {
37
+ try {
38
+ const list = listEvidencePacks({ max_items: 50 });
39
+ const packs = list.evidence_packs.map((pack) => augmentEvidencePackSummary(pack));
40
+ // Detect lineages that exist but have no exported evidence pack yet, so the
41
+ // dashboard can show an "Export evidence pack" action for the most recent one.
42
+ const lineagesRoot = join(config.workspaceRoot, ".patchwarden", "lineages");
43
+ let lineageCount = 0;
44
+ const exportedIds = new Set(packs.map((p) => p.lineage_id));
45
+ const pendingLineageIds: string[] = [];
46
+ if (existsSync(lineagesRoot)) {
47
+ const dirs = readdirSync(lineagesRoot, { withFileTypes: true }).filter((e) => e.isDirectory());
48
+ lineageCount = dirs.length;
49
+ for (const dir of dirs) {
50
+ if (!exportedIds.has(dir.name)) pendingLineageIds.push(dir.name);
51
+ }
52
+ }
53
+ sendJson(res, 200, {
54
+ evidence_packs: packs,
55
+ total: packs.length,
56
+ truncated: list.truncated,
57
+ has_lineages: lineageCount > 0,
58
+ lineage_count: lineageCount,
59
+ pending_lineage_ids: pendingLineageIds.slice(0, 20),
60
+ reason: null,
61
+ });
62
+ } catch (err) {
63
+ sendJson(res, 200, { evidence_packs: [], total: 0, reason: errorMessage(err) });
64
+ }
65
+ }
66
+
67
+ export function handleEvidencePackDetail(res: ServerResponse, lineageId: string): void {
68
+ try {
69
+ if (!/^[A-Za-z0-9_-]+$/.test(lineageId)) {
70
+ sendJson(res, 400, { error: "Invalid lineage id" });
71
+ return;
72
+ }
73
+ const pack = readEvidencePack(lineageId);
74
+ if (!pack) {
75
+ sendJson(res, 404, { error: "Evidence pack not found" });
76
+ return;
77
+ }
78
+ sendJson(res, 200, augmentEvidencePackSummary(pack));
79
+ } catch (err) {
80
+ sendJson(res, 200, { lineage_id: lineageId, error: errorMessage(err), bounded: true });
81
+ }
82
+ }
83
+
84
+ export function handleEvidencePackExport(res: ServerResponse, lineageId: string): void {
85
+ try {
86
+ if (!/^[A-Za-z0-9_-]+$/.test(lineageId)) {
87
+ sendJson(res, 400, { error: "Invalid lineage id" });
88
+ return;
89
+ }
90
+ // Check lineage exists.
91
+ const lineageFile = join(config.workspaceRoot, ".patchwarden", "lineages", lineageId, "lineage.json");
92
+ if (!existsSync(lineageFile)) {
93
+ sendJson(res, 404, { error: "Lineage not found" });
94
+ return;
95
+ }
96
+ // Check if already exported.
97
+ const evidenceJsonPath = join(config.workspaceRoot, ".patchwarden", "evidence-packs", lineageId, "evidence.json");
98
+ if (existsSync(evidenceJsonPath)) {
99
+ sendJson(res, 200, { ok: true, already_exported: true, lineage_id: lineageId });
100
+ return;
101
+ }
102
+ const pack = exportTaskEvidencePack({ lineage_id: lineageId });
103
+ sendJson(res, 200, { ok: true, exported: true, lineage_id: lineageId, evidence_pack_id: pack.evidence_pack_id });
104
+ } catch (err) {
105
+ sendJson(res, 200, { ok: false, lineage_id: lineageId, error: errorMessage(err) });
106
+ }
107
+ }