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
package/dist/doctor.js CHANGED
@@ -22,31 +22,8 @@ import { buildToolRegistry } from "./tools/toolRegistry.js";
22
22
  import { runAllSchemaDriftChecks } from "./tools/schemaDriftCheck.js";
23
23
  import { runReleaseGateCheck } from "./release/releaseGate.js";
24
24
  import { PATCHWARDEN_VERSION } from "./version.js";
25
- // ── State ──────────────────────────────────────────────────────────
26
- let ok = 0;
27
- let warn = 0;
28
- let fail = 0;
29
- const results = [];
30
- function check(name, condition, detail) {
31
- if (condition) {
32
- results.push(`[OK] ${name}`);
33
- ok++;
34
- }
35
- else {
36
- results.push(`[FAIL] ${name}${detail ? " — " + detail : ""}`);
37
- fail++;
38
- }
39
- }
40
- function warnCheck(name, condition, detail) {
41
- if (condition) {
42
- results.push(`[OK] ${name}`);
43
- ok++;
44
- }
45
- else {
46
- results.push(`[WARN] ${name}${detail ? " — " + detail : ""}`);
47
- warn++;
48
- }
49
- }
25
+ import { logger } from "./logging.js";
26
+ // ── Helpers ────────────────────────────────────────────────────────
50
27
  function cmd(cmdStr) {
51
28
  try {
52
29
  return execSync(cmdStr, {
@@ -59,81 +36,124 @@ function cmd(cmdStr) {
59
36
  return "";
60
37
  }
61
38
  }
62
- // ══════════════════════════════════════════════════════════════════
63
- async function main() {
64
- console.log("PatchWarden Doctor\n");
65
- const allowDefaultConfig = process.argv.includes("--allow-default-config");
66
- // 1. Node version
67
- const nodeVer = process.version;
68
- const nodeMajor = parseInt(nodeVer.slice(1).split(".")[0]);
69
- check("Node.js version", nodeMajor >= 18, nodeMajor < 18 ? `v${nodeVer} — need >=18.0.0` : `v${nodeVer}`);
70
- // 2. npm
71
- const npmVer = cmd("npm --version");
72
- check("npm available", npmVer !== "", npmVer || "npm not found in PATH");
73
- // Local-only medium-risk confirmation entrypoint. It must exist as a package
74
- // binary but must not be exposed as an MCP tool.
75
- try {
76
- const packageJson = JSON.parse(readFileSync(resolve(process.cwd(), "package.json"), "utf-8"));
77
- check("patchwarden-confirm package binary", packageJson.bin?.["patchwarden-confirm"] === "dist/assessments/confirmCli.js", packageJson.bin?.["patchwarden-confirm"] || "missing");
78
- check("patchwarden-confirm compiled entrypoint", existsSync(resolve(process.cwd(), "dist/assessments/confirmCli.js")), "run npm.cmd run build");
79
- }
80
- catch (error) {
81
- check("patchwarden-confirm package binary", false, error instanceof Error ? error.message : String(error));
39
+ function okResult(message, details) {
40
+ return { level: "OK", message, details };
41
+ }
42
+ function warnResult(message, details) {
43
+ return { level: "WARN", message, details };
44
+ }
45
+ function failResult(message, details) {
46
+ return { level: "FAIL", message, details };
47
+ }
48
+ /** Mirrors original check(): OK if condition true, FAIL otherwise. */
49
+ function checkResult(name, condition, detail) {
50
+ return condition ? okResult(name, detail) : failResult(name, detail);
51
+ }
52
+ /** Mirrors original warnCheck(): OK if condition true, WARN otherwise. */
53
+ function warnCheckResult(name, condition, detail) {
54
+ return condition ? okResult(name, detail) : warnResult(name, detail);
55
+ }
56
+ function formatResult(result) {
57
+ const tag = result.level === "OK" ? "[OK] " : result.level === "WARN" ? "[WARN] " : "[FAIL] ";
58
+ if (result.level !== "OK" && result.details) {
59
+ return `${tag}${result.message} — ${result.details}`;
82
60
  }
83
- // 3. Git
84
- const gitVer = cmd("git --version");
85
- warnCheck("Git available", gitVer !== "", gitVer || "git not found — runner git.diff will not work");
86
- // 4. Config file exists
87
- const configPaths = [
88
- resolve(process.cwd(), "patchwarden.config.json"),
89
- process.env.PATCHWARDEN_CONFIG || "",
90
- ].filter(Boolean);
91
- let configPathUsed = "";
92
- for (const p of configPaths) {
93
- if (existsSync(p)) {
94
- configPathUsed = p;
95
- break;
61
+ return `${tag}${result.message}`;
62
+ }
63
+ // ── Checks ─────────────────────────────────────────────────────────
64
+ const checkNodeVersion = {
65
+ id: "node-version",
66
+ description: "Node.js version",
67
+ run() {
68
+ const nodeVer = process.version;
69
+ const nodeMajor = parseInt(nodeVer.slice(1).split(".")[0]);
70
+ const detail = nodeMajor < 18 ? `v${nodeVer} — need >=18.0.0` : `v${nodeVer}`;
71
+ return [checkResult("Node.js version", nodeMajor >= 18, detail)];
72
+ },
73
+ };
74
+ const checkNpmAvailable = {
75
+ id: "npm-available",
76
+ description: "npm available",
77
+ run() {
78
+ const npmVer = cmd("npm --version");
79
+ return [checkResult("npm available", npmVer !== "", npmVer || "npm not found in PATH")];
80
+ },
81
+ };
82
+ // Local-only medium-risk confirmation entrypoint. It must exist as a package
83
+ // binary but must not be exposed as an MCP tool.
84
+ const checkConfirmPackage = {
85
+ id: "confirm-package",
86
+ description: "patchwarden-confirm package",
87
+ run() {
88
+ try {
89
+ const packageJson = JSON.parse(readFileSync(resolve(process.cwd(), "package.json"), "utf-8"));
90
+ return [
91
+ checkResult("patchwarden-confirm package binary", packageJson.bin?.["patchwarden-confirm"] === "dist/assessments/confirmCli.js", packageJson.bin?.["patchwarden-confirm"] || "missing"),
92
+ checkResult("patchwarden-confirm compiled entrypoint", existsSync(resolve(process.cwd(), "dist/assessments/confirmCli.js")), "run npm.cmd run build"),
93
+ ];
96
94
  }
97
- }
98
- const configDetail = configPathUsed
99
- ? configPathUsed
100
- : 'Create one: cp examples/config.example.json patchwarden.config.json';
101
- if (allowDefaultConfig) {
102
- warnCheck("Config file exists", configPathUsed !== "", configDetail);
103
- }
104
- else {
105
- check("Config file exists", configPathUsed !== "", configDetail);
106
- }
107
- // 5. PATCHWARDEN_CONFIG env
108
- if (process.env.PATCHWARDEN_CONFIG) {
109
- results.push(`[OK] PATCHWARDEN_CONFIG = ${process.env.PATCHWARDEN_CONFIG}`);
110
- ok++;
111
- }
112
- else {
113
- results.push(`[OK] PATCHWARDEN_CONFIG not set (using default: patchwarden.config.json)`);
114
- ok++;
115
- }
116
- // Load config (may fail)
117
- let config = null;
118
- try {
119
- config = getConfig();
120
- check("Config parseable", true, `workspaceRoot: ${config.workspaceRoot}`);
121
- }
122
- catch (err) {
123
- check("Config parseable", false, err instanceof Error ? err.message : String(err));
124
- }
125
- // 6. workspaceRoot checks
126
- if (config) {
127
- const ws = normalize(resolve(config.workspaceRoot));
95
+ catch (error) {
96
+ return [checkResult("patchwarden-confirm package binary", false, error instanceof Error ? error.message : String(error))];
97
+ }
98
+ },
99
+ };
100
+ const checkGitAvailable = {
101
+ id: "git-available",
102
+ description: "Git available",
103
+ run() {
104
+ const gitVer = cmd("git --version");
105
+ return [warnCheckResult("Git available", gitVer !== "", gitVer || "git not found — runner git.diff will not work")];
106
+ },
107
+ };
108
+ const checkConfigFile = {
109
+ id: "config-file",
110
+ description: "Config file exists",
111
+ run(context) {
112
+ const configDetail = context.configPathUsed
113
+ ? context.configPathUsed
114
+ : 'Create one: cp examples/config.example.json patchwarden.config.json';
115
+ if (context.allowDefaultConfig) {
116
+ return [warnCheckResult("Config file exists", context.configPathUsed !== "", configDetail)];
117
+ }
118
+ return [checkResult("Config file exists", context.configPathUsed !== "", configDetail)];
119
+ },
120
+ };
121
+ const checkPatchwardenConfigEnv = {
122
+ id: "patchwarden-config-env",
123
+ description: "PATCHWARDEN_CONFIG env",
124
+ run() {
125
+ if (process.env.PATCHWARDEN_CONFIG) {
126
+ return [okResult(`PATCHWARDEN_CONFIG = ${process.env.PATCHWARDEN_CONFIG}`)];
127
+ }
128
+ return [okResult("PATCHWARDEN_CONFIG not set (using default: patchwarden.config.json)")];
129
+ },
130
+ };
131
+ const checkConfigParseable = {
132
+ id: "config-parseable",
133
+ description: "Config parseable",
134
+ run(context) {
135
+ if (context.config) {
136
+ return [checkResult("Config parseable", true, `workspaceRoot: ${context.config.workspaceRoot}`)];
137
+ }
138
+ return [checkResult("Config parseable", false, context.configError || "unknown error")];
139
+ },
140
+ };
141
+ const checkWorkspaceRoot = {
142
+ id: "workspace-root",
143
+ description: "workspaceRoot checks",
144
+ run(context) {
145
+ if (!context.config)
146
+ return [];
147
+ const results = [];
148
+ const ws = normalize(resolve(context.config.workspaceRoot));
128
149
  const exists = existsSync(ws);
129
- check("workspaceRoot exists", exists, ws);
150
+ results.push(checkResult("workspaceRoot exists", exists, ws));
130
151
  let isDir = false;
131
152
  try {
132
153
  isDir = statSync(ws).isDirectory();
133
154
  }
134
155
  catch { }
135
- check("workspaceRoot is directory", isDir, ws);
136
- // Danger checks
156
+ results.push(checkResult("workspaceRoot is directory", isDir, ws));
137
157
  const dangerousRoots = [
138
158
  { pattern: /^[A-Za-z]:\\?$/, label: "drive root" },
139
159
  { pattern: /\\Users\\[^\\]+$/, label: "user home directory" },
@@ -142,258 +162,365 @@ async function main() {
142
162
  { pattern: /\\Documents$/, label: "Documents" },
143
163
  ];
144
164
  for (const { pattern, label } of dangerousRoots) {
145
- const matches = pattern.test(ws);
146
- if (matches) {
147
- results.push(`[WARN] workspaceRoot is ${label}: ${ws} — consider narrowing to a project directory`);
148
- warn++;
165
+ if (pattern.test(ws)) {
166
+ results.push(warnResult(`workspaceRoot is ${label}: ${ws} — consider narrowing to a project directory`));
149
167
  }
150
168
  }
151
- }
152
- // 7. Path guard test
153
- if (config) {
169
+ return results;
170
+ },
171
+ };
172
+ const checkPathGuard = {
173
+ id: "path-guard",
174
+ description: "Path guard test",
175
+ run(context) {
176
+ if (!context.config)
177
+ return [];
178
+ const results = [];
154
179
  try {
155
- guardPath("test-file.txt", config.workspaceRoot);
156
- results.push(`[OK] pathGuard allows workspace-internal path`);
157
- ok++;
180
+ guardPath("test-file.txt", context.config.workspaceRoot);
181
+ results.push(okResult("pathGuard allows workspace-internal path"));
158
182
  }
159
183
  catch (err) {
160
- results.push(`[FAIL] pathGuard rejects internal path: ${err instanceof Error ? err.message : String(err)}`);
161
- fail++;
184
+ results.push(failResult(`pathGuard rejects internal path: ${err instanceof Error ? err.message : String(err)}`));
162
185
  }
163
186
  try {
164
- guardPath("../outside", config.workspaceRoot);
165
- results.push(`[FAIL] pathGuard should have blocked ../escape`);
166
- fail++;
187
+ guardPath("../outside", context.config.workspaceRoot);
188
+ results.push(failResult("pathGuard should have blocked ../escape"));
167
189
  }
168
190
  catch {
169
- results.push(`[OK] pathGuard blocks ../ path escape`);
170
- ok++;
191
+ results.push(okResult("pathGuard blocks ../ path escape"));
171
192
  }
172
193
  try {
173
- const relativeRepo = guardWorkspacePath(".", config.workspaceRoot);
174
- const absoluteRepo = guardWorkspacePath(config.workspaceRoot, config.workspaceRoot);
175
- check("repo_path resolver supports relative and absolute paths", relativeRepo === absoluteRepo, relativeRepo);
194
+ const relativeRepo = guardWorkspacePath(".", context.config.workspaceRoot);
195
+ const absoluteRepo = guardWorkspacePath(context.config.workspaceRoot, context.config.workspaceRoot);
196
+ results.push(checkResult("repo_path resolver supports relative and absolute paths", relativeRepo === absoluteRepo, relativeRepo));
176
197
  }
177
198
  catch (error) {
178
- check("repo_path resolver supports relative and absolute paths", false, error instanceof Error ? error.message : String(error));
199
+ results.push(checkResult("repo_path resolver supports relative and absolute paths", false, error instanceof Error ? error.message : String(error)));
179
200
  }
180
- }
181
- // 8. Sensitive file guard test
182
- const sensitivePaths = [".env", ".ssh/id_rsa", "token.json", "credentials"];
183
- for (const sp of sensitivePaths) {
184
- const blocked = isSensitivePath(sp);
185
- if (blocked) {
186
- results.push(`[OK] sensitiveGuard blocks "${sp}"`);
187
- ok++;
201
+ return results;
202
+ },
203
+ };
204
+ const checkSensitiveGuard = {
205
+ id: "sensitive-guard",
206
+ description: "Sensitive file guard test",
207
+ run() {
208
+ const results = [];
209
+ const sensitivePaths = [".env", ".ssh/id_rsa", "token.json", "credentials"];
210
+ for (const sp of sensitivePaths) {
211
+ const blocked = isSensitivePath(sp);
212
+ if (blocked) {
213
+ results.push(okResult(`sensitiveGuard blocks "${sp}"`));
214
+ }
215
+ else {
216
+ results.push(failResult(`sensitiveGuard does NOT block "${sp}"`));
217
+ }
188
218
  }
189
- else {
190
- results.push(`[FAIL] sensitiveGuard does NOT block "${sp}"`);
191
- fail++;
219
+ return results;
220
+ },
221
+ };
222
+ const checkSavePlanRules = {
223
+ id: "save-plan-rules",
224
+ description: "save_plan guard test",
225
+ run() {
226
+ const results = [];
227
+ try {
228
+ guardPlanContent("Normal build plan", "Run npm test, npm run lint, release check, and npm run dist.");
229
+ results.push(okResult("save_plan allows normal development plans"));
192
230
  }
193
- }
194
- try {
195
- guardPlanContent("Normal build plan", "Run npm test, npm run lint, release check, and npm run dist.");
196
- results.push("[OK] save_plan allows normal development plans");
197
- ok++;
198
- }
199
- catch {
200
- results.push("[FAIL] save_plan incorrectly blocks a normal development plan");
201
- fail++;
202
- }
203
- try {
204
- guardPlanContent("Unsafe plan", "Read the .env access token and export it.");
205
- results.push("[FAIL] save_plan security rule did not block credential access");
206
- fail++;
207
- }
208
- catch {
209
- results.push("[OK] save_plan security rules loaded");
210
- ok++;
211
- }
212
- const requiredReadOnlyFiles = ["status.json", "result.md", "result.json", "diff.patch", "file-stats.json", "test.log", "verify.json"];
213
- check("Read-only task artifact allowlist", requiredReadOnlyFiles.every((name) => TASK_READ_ONLY_FILES.includes(name)), requiredReadOnlyFiles.join(", "));
214
- const packageJson = JSON.parse(readFileSync(resolve(process.cwd(), "package.json"), "utf-8"));
215
- check("Server version matches package.json", packageJson.version === PATCHWARDEN_VERSION, `${PATCHWARDEN_VERSION} vs ${packageJson.version}`);
216
- check("Manifest preflight script exists", existsSync(resolve(process.cwd(), "scripts/checks/mcp-manifest-check.js")), "scripts/checks/mcp-manifest-check.js");
217
- const previousProfile = process.env.PATCHWARDEN_TOOL_PROFILE;
218
- try {
219
- process.env.PATCHWARDEN_TOOL_PROFILE = "full";
220
- const fullTools = getToolDefs();
221
- const coreTools = selectToolsForProfile(fullTools, "chatgpt_core", config?.enableDirectProfile);
222
- const createSchema = coreTools.find((tool) => tool.name === "create_task")?.inputSchema;
223
- const waitSchema = coreTools.find((tool) => tool.name === "wait_for_task")?.inputSchema;
224
- check("Full tool profile exposes 64 tools", fullTools.length === 64, `${fullTools.length} tools`);
225
- check(`chatgpt_core profile exposes the exact ${CHATGPT_CORE_TOOL_NAMES.length}-tool manifest`, JSON.stringify(coreTools.map((tool) => tool.name)) === JSON.stringify(CHATGPT_CORE_TOOL_NAMES), coreTools.map((tool) => tool.name).join(", "));
226
- check("Core task schemas expose inline_plan, verify_commands, and wait aliases", Boolean(createSchema?.properties?.inline_plan &&
227
- createSchema?.properties?.verify_commands &&
228
- waitSchema?.properties?.timeout_seconds &&
229
- waitSchema?.properties?.wait_seconds));
230
- // chatgpt_direct profile checks
231
- const directDisabledTools = selectToolsForProfile(fullTools, "chatgpt_direct", false);
232
- check("chatgpt_direct disabled exposes only health_check (degraded mode)", directDisabledTools.length === 1 && directDisabledTools[0].name === "health_check", `${directDisabledTools.map((t) => t.name).join(", ")}`);
233
- if (config?.enableDirectProfile) {
234
- const directEnabledTools = selectToolsForProfile(fullTools, "chatgpt_direct", true);
235
- check(`chatgpt_direct enabled exposes the exact ${CHATGPT_DIRECT_TOOL_NAMES.length}-tool manifest`, JSON.stringify(directEnabledTools.map((tool) => tool.name)) === JSON.stringify(CHATGPT_DIRECT_TOOL_NAMES), directEnabledTools.map((tool) => tool.name).join(", "));
236
- }
237
- else {
238
- results.push(`[OK] chatgpt_direct enabled check skipped (enableDirectProfile=false)`);
239
- ok++;
231
+ catch {
232
+ results.push(failResult("save_plan incorrectly blocks a normal development plan"));
240
233
  }
241
- // 16. Schema drift 检查(v0.9.0)—— warn 级别,不阻断 doctor:ci
242
- // fullTools 同时构建 registry toolDefs Map,确保 schema digest 比对基准一致。
243
- const driftRegistry = buildToolRegistry(fullTools);
244
- const driftToolDefs = new Map();
245
- for (const tool of fullTools) {
246
- driftToolDefs.set(tool.name, { inputSchema: tool.inputSchema });
234
+ try {
235
+ guardPlanContent("Unsafe plan", "Read the .env access token and export it.");
236
+ results.push(failResult("save_plan security rule did not block credential access"));
247
237
  }
248
- const driftResult = runAllSchemaDriftChecks(driftRegistry, driftToolDefs);
249
- if (driftResult.ok) {
250
- results.push(`[OK] Schema drift check — no drift detected`);
251
- ok++;
238
+ catch {
239
+ results.push(okResult("save_plan security rules loaded"));
252
240
  }
253
- else {
254
- // 每个 warning 单独输出为 WARN,与其他自检项的多警告模式一致
255
- for (const w of driftResult.warnings) {
256
- results.push(`[WARN] Schema drift: ${w}`);
257
- warn++;
241
+ return results;
242
+ },
243
+ };
244
+ const checkTaskArtifactAllowlist = {
245
+ id: "task-artifact-allowlist",
246
+ description: "Read-only task artifact allowlist",
247
+ run() {
248
+ const requiredReadOnlyFiles = ["status.json", "result.md", "result.json", "diff.patch", "file-stats.json", "test.log", "verify.json"];
249
+ return [
250
+ checkResult("Read-only task artifact allowlist", requiredReadOnlyFiles.every((name) => TASK_READ_ONLY_FILES.includes(name)), requiredReadOnlyFiles.join(", ")),
251
+ ];
252
+ },
253
+ };
254
+ const checkServerVersion = {
255
+ id: "server-version",
256
+ description: "Server version and manifest preflight",
257
+ run() {
258
+ const packageJson = JSON.parse(readFileSync(resolve(process.cwd(), "package.json"), "utf-8"));
259
+ return [
260
+ checkResult("Server version matches package.json", packageJson.version === PATCHWARDEN_VERSION, `${PATCHWARDEN_VERSION} vs ${packageJson.version}`),
261
+ checkResult("Manifest preflight script exists", existsSync(resolve(process.cwd(), "scripts/checks/mcp-manifest-check.js")), "scripts/checks/mcp-manifest-check.js"),
262
+ ];
263
+ },
264
+ };
265
+ const checkToolProfiles = {
266
+ id: "tool-profiles",
267
+ description: "Tool profile and schema drift checks",
268
+ run(context) {
269
+ const results = [];
270
+ const previousProfile = process.env.PATCHWARDEN_TOOL_PROFILE;
271
+ try {
272
+ process.env.PATCHWARDEN_TOOL_PROFILE = "full";
273
+ const fullTools = getToolDefs();
274
+ const coreTools = selectToolsForProfile(fullTools, "chatgpt_core", context.config?.enableDirectProfile);
275
+ const createSchema = coreTools.find((tool) => tool.name === "create_task")?.inputSchema;
276
+ const waitSchema = coreTools.find((tool) => tool.name === "wait_for_task")?.inputSchema;
277
+ results.push(checkResult("Full tool profile exposes 66 tools", fullTools.length === 66, `${fullTools.length} tools`));
278
+ results.push(checkResult(`chatgpt_core profile exposes the exact ${CHATGPT_CORE_TOOL_NAMES.length}-tool manifest`, JSON.stringify(coreTools.map((tool) => tool.name)) === JSON.stringify(CHATGPT_CORE_TOOL_NAMES), coreTools.map((tool) => tool.name).join(", ")));
279
+ results.push(checkResult("Core task schemas expose inline_plan, verify_commands, and wait aliases", Boolean(createSchema?.properties?.inline_plan &&
280
+ createSchema?.properties?.verify_commands &&
281
+ waitSchema?.properties?.timeout_seconds &&
282
+ waitSchema?.properties?.wait_seconds)));
283
+ // chatgpt_direct profile checks
284
+ const directDisabledTools = selectToolsForProfile(fullTools, "chatgpt_direct", false);
285
+ results.push(checkResult("chatgpt_direct disabled exposes only health_check (degraded mode)", directDisabledTools.length === 1 && directDisabledTools[0].name === "health_check", `${directDisabledTools.map((t) => t.name).join(", ")}`));
286
+ if (context.config?.enableDirectProfile) {
287
+ const directEnabledTools = selectToolsForProfile(fullTools, "chatgpt_direct", true);
288
+ results.push(checkResult(`chatgpt_direct enabled exposes the exact ${CHATGPT_DIRECT_TOOL_NAMES.length}-tool manifest`, JSON.stringify(directEnabledTools.map((tool) => tool.name)) === JSON.stringify(CHATGPT_DIRECT_TOOL_NAMES), directEnabledTools.map((tool) => tool.name).join(", ")));
289
+ }
290
+ else {
291
+ results.push(okResult("chatgpt_direct enabled check skipped (enableDirectProfile=false)"));
292
+ }
293
+ // Schema drift 检查(v0.9.0)—— warn 级别,不阻断 doctor:ci
294
+ const driftRegistry = buildToolRegistry(fullTools);
295
+ const driftToolDefs = new Map();
296
+ for (const tool of fullTools) {
297
+ driftToolDefs.set(tool.name, { inputSchema: tool.inputSchema });
298
+ }
299
+ const driftResult = runAllSchemaDriftChecks(driftRegistry, driftToolDefs);
300
+ if (driftResult.ok) {
301
+ results.push(okResult("Schema drift check — no drift detected"));
302
+ }
303
+ else {
304
+ for (const w of driftResult.warnings) {
305
+ results.push(warnResult(`Schema drift: ${w}`));
306
+ }
258
307
  }
259
308
  }
260
- }
261
- finally {
262
- if (previousProfile === undefined)
263
- delete process.env.PATCHWARDEN_TOOL_PROFILE;
264
- else
265
- process.env.PATCHWARDEN_TOOL_PROFILE = previousProfile;
266
- }
267
- // 17. Release gate module loadable (v1.0.0) — module integrity only.
268
- // Does NOT execute local_ready (would recurse into doctor:ci).
269
- try {
270
- const releaseGateReady = typeof runReleaseGateCheck === "function";
271
- check("Release gate module loadable", releaseGateReady, releaseGateReady ? "runReleaseGateCheck exported" : "runReleaseGateCheck missing or invalid");
272
- }
273
- catch (error) {
274
- check("Release gate module loadable", false, error instanceof Error ? error.message : String(error));
275
- }
276
- // 9. HTTP port check
277
- const httpPort = config?.http?.port || 7331;
278
- try {
279
- const server = createServer();
280
- await new Promise((resolvePort, rejectPort) => {
281
- server.once("error", rejectPort);
282
- server.listen(httpPort, "127.0.0.1", () => {
283
- server.close();
284
- resolvePort();
309
+ finally {
310
+ if (previousProfile === undefined)
311
+ delete process.env.PATCHWARDEN_TOOL_PROFILE;
312
+ else
313
+ process.env.PATCHWARDEN_TOOL_PROFILE = previousProfile;
314
+ }
315
+ return results;
316
+ },
317
+ };
318
+ // Release gate module loadable (v1.0.0) — module integrity only.
319
+ // Does NOT execute local_ready (would recurse into doctor:ci).
320
+ const checkReleaseGate = {
321
+ id: "release-gate",
322
+ description: "Release gate module loadable",
323
+ run() {
324
+ try {
325
+ const releaseGateReady = typeof runReleaseGateCheck === "function";
326
+ return [
327
+ checkResult("Release gate module loadable", releaseGateReady, releaseGateReady ? "runReleaseGateCheck exported" : "runReleaseGateCheck missing or invalid"),
328
+ ];
329
+ }
330
+ catch (error) {
331
+ return [checkResult("Release gate module loadable", false, error instanceof Error ? error.message : String(error))];
332
+ }
333
+ },
334
+ };
335
+ const checkHttpPort = {
336
+ id: "http-port",
337
+ description: "HTTP port check",
338
+ async run(context) {
339
+ const httpPort = context.config?.http?.port || context.config?.httpPort || 7331;
340
+ try {
341
+ const server = createServer();
342
+ await new Promise((resolvePort, rejectPort) => {
343
+ server.once("error", rejectPort);
344
+ server.listen(httpPort, "127.0.0.1", () => {
345
+ server.close();
346
+ resolvePort();
347
+ });
285
348
  });
286
- });
287
- results.push(`[OK] HTTP port ${httpPort} is free`);
288
- ok++;
289
- }
290
- catch {
291
- results.push(`[WARN] HTTP port ${httpPort} is in use — change http.port in config`);
292
- warn++;
293
- }
294
- // 10-12. dist file checks
295
- const distChecks = [
296
- { file: "dist/index.js", label: "stdio MCP entry", cmd: "npm run build" },
297
- { file: "dist/httpServer.js", label: "HTTP MCP entry", cmd: "npm run build" },
298
- { file: "dist/runner/watch.js", label: "watcher entry (npm run watch)", cmd: "npm run build" },
299
- ];
300
- for (const { file, label, cmd: buildCmd } of distChecks) {
301
- check(`${label} exists`, existsSync(resolve(process.cwd(), file)), existsSync(resolve(process.cwd(), file)) ? file : `Missing — run: ${buildCmd}`);
302
- }
303
- // New tool registrations check
304
- const newTools = [
305
- "listTasks",
306
- "listAgents",
307
- "cancelTask",
308
- "killTask",
309
- "retryTask",
310
- "getTaskProgress",
311
- "getTaskSummary",
312
- "waitForTask",
313
- "getTaskStdoutTail",
314
- "healthCheck",
315
- "auditTask",
316
- ];
317
- for (const t of newTools) {
318
- const compiled = resolve(process.cwd(), "dist/tools", `${t}.js`);
319
- check(`Tool module: ${t}`, existsSync(compiled), existsSync(compiled) ? "compiled" : "missing");
320
- }
321
- // Direct tool module checks
322
- const directToolModules = [
323
- "createDirectSession",
324
- "searchWorkspace",
325
- "applyPatch",
326
- "runVerification",
327
- "finalizeDirectSession",
328
- "auditSession",
329
- ];
330
- for (const t of directToolModules) {
331
- const compiled = resolve(process.cwd(), "dist/tools", `${t}.js`);
332
- check(`Direct tool module: ${t}`, existsSync(compiled), existsSync(compiled) ? "compiled" : "missing");
333
- }
334
- // Direct support module checks
335
- const directSupportModules = [
336
- "directSessionStore",
337
- "directGuards",
338
- "directPatch",
339
- "directVerification",
340
- "directAudit",
341
- ];
342
- for (const t of directSupportModules) {
343
- const compiled = resolve(process.cwd(), "dist/direct", `${t}.js`);
344
- check(`Direct support module: ${t}`, existsSync(compiled), existsSync(compiled) ? "compiled" : "missing");
345
- }
346
- // Task directory writable
347
- if (config) {
348
- const tasksDir = resolve(config.workspaceRoot, config.tasksDir);
349
+ return [okResult(`HTTP port ${httpPort} is free`)];
350
+ }
351
+ catch {
352
+ return [warnResult(`HTTP port ${httpPort} is in use — change http.port in config`)];
353
+ }
354
+ },
355
+ };
356
+ const checkDistFiles = {
357
+ id: "dist-files",
358
+ description: "dist file checks",
359
+ run() {
360
+ const distChecks = [
361
+ { file: "dist/index.js", label: "stdio MCP entry", cmd: "npm run build" },
362
+ { file: "dist/httpServer.js", label: "HTTP MCP entry", cmd: "npm run build" },
363
+ { file: "dist/runner/watch.js", label: "watcher entry (npm run watch)", cmd: "npm run build" },
364
+ ];
365
+ const results = [];
366
+ for (const { file, label, cmd: buildCmd } of distChecks) {
367
+ const exists = existsSync(resolve(process.cwd(), file));
368
+ results.push(checkResult(`${label} exists`, exists, exists ? file : `Missing — run: ${buildCmd}`));
369
+ }
370
+ return results;
371
+ },
372
+ };
373
+ const checkToolModules = {
374
+ id: "tool-modules",
375
+ description: "Tool module checks",
376
+ run() {
377
+ const newTools = [
378
+ "listTasks",
379
+ "listAgents",
380
+ "cancelTask",
381
+ "killTask",
382
+ "retryTask",
383
+ "getTaskProgress",
384
+ "getTaskSummary",
385
+ "waitForTask",
386
+ "getTaskStdoutTail",
387
+ "healthCheck",
388
+ "auditTask",
389
+ ];
390
+ const results = [];
391
+ for (const t of newTools) {
392
+ const compiled = resolve(process.cwd(), "dist/tools", `${t}.js`);
393
+ const exists = existsSync(compiled);
394
+ results.push(checkResult(`Tool module: ${t}`, exists, exists ? "compiled" : "missing"));
395
+ }
396
+ return results;
397
+ },
398
+ };
399
+ const checkDirectToolModules = {
400
+ id: "direct-tool-modules",
401
+ description: "Direct tool module checks",
402
+ run() {
403
+ const directToolModules = [
404
+ "createDirectSession",
405
+ "searchWorkspace",
406
+ "applyPatch",
407
+ "runVerification",
408
+ "finalizeDirectSession",
409
+ "auditSession",
410
+ ];
411
+ const results = [];
412
+ for (const t of directToolModules) {
413
+ const compiled = resolve(process.cwd(), "dist/tools", `${t}.js`);
414
+ const exists = existsSync(compiled);
415
+ results.push(checkResult(`Direct tool module: ${t}`, exists, exists ? "compiled" : "missing"));
416
+ }
417
+ return results;
418
+ },
419
+ };
420
+ const checkDirectSupportModules = {
421
+ id: "direct-support-modules",
422
+ description: "Direct support module checks",
423
+ run() {
424
+ const directSupportModules = [
425
+ "directSessionStore",
426
+ "directGuards",
427
+ "directPatch",
428
+ "directVerification",
429
+ "directAudit",
430
+ ];
431
+ const results = [];
432
+ for (const t of directSupportModules) {
433
+ const compiled = resolve(process.cwd(), "dist/direct", `${t}.js`);
434
+ const exists = existsSync(compiled);
435
+ results.push(checkResult(`Direct support module: ${t}`, exists, exists ? "compiled" : "missing"));
436
+ }
437
+ return results;
438
+ },
439
+ };
440
+ const checkTaskDirectoryWritable = {
441
+ id: "task-directory-writable",
442
+ description: "Task directory and workspaceRoot writable",
443
+ run(context) {
444
+ if (!context.config)
445
+ return [];
446
+ const results = [];
447
+ const tasksDir = resolve(context.config.workspaceRoot, context.config.tasksDir);
349
448
  try {
350
449
  mkdirSync(tasksDir, { recursive: true });
351
450
  const testFile = join(tasksDir, ".doctor-write-test");
352
451
  writeFileSync(testFile, "ok", "utf-8");
353
452
  rmSync(testFile);
354
- check("Task directory writable", true, tasksDir);
453
+ results.push(checkResult("Task directory writable", true, tasksDir));
355
454
  const sampleTaskDir = join(tasksDir, ".doctor-sample-task");
356
455
  mkdirSync(sampleTaskDir, { recursive: true });
357
456
  const sampleStatus = join(sampleTaskDir, "status.json");
358
457
  writeFileSync(sampleStatus, JSON.stringify({ status: "doctor" }), "utf-8");
359
458
  const sampleReadable = JSON.parse(readFileSync(sampleStatus, "utf-8")).status === "doctor";
360
459
  rmSync(sampleTaskDir, { recursive: true, force: true });
361
- check("Example task directory read/write", sampleReadable, sampleTaskDir);
460
+ results.push(checkResult("Example task directory read/write", sampleReadable, sampleTaskDir));
362
461
  }
363
462
  catch {
364
- warnCheck("Task directory writable", false, tasksDir);
463
+ results.push(warnCheckResult("Task directory writable", false, tasksDir));
365
464
  }
366
465
  // workspaceRoot writable
367
466
  try {
368
- const testFile = resolve(config.workspaceRoot, ".doctor-write-test");
467
+ const testFile = resolve(context.config.workspaceRoot, ".doctor-write-test");
369
468
  writeFileSync(testFile, "ok", "utf-8");
370
469
  rmSync(testFile);
371
- check("workspaceRoot writable", true, config.workspaceRoot);
470
+ results.push(checkResult("workspaceRoot writable", true, context.config.workspaceRoot));
372
471
  }
373
472
  catch {
374
- warnCheck("workspaceRoot writable", false, config.workspaceRoot);
473
+ results.push(warnCheckResult("workspaceRoot writable", false, context.config.workspaceRoot));
375
474
  }
376
- }
377
- if (config) {
378
- check("Watcher stale threshold is valid", config.watcherStaleSeconds >= 5 && config.watcherStaleSeconds <= 3600, `${config.watcherStaleSeconds}s`);
379
- }
380
- // allowedTestCommands has npm test
381
- if (config) {
382
- const hasNpmTest = config.allowedTestCommands.some((c) => c === "npm test" || c === "npm run test");
383
- warnCheck("allowedTestCommands includes npm test", hasNpmTest, hasNpmTest ? "present" : "npm test is missing — add it to allowedTestCommands");
384
- }
385
- if (config) {
386
- check("Task timeout defaults are valid", config.defaultTaskTimeoutSeconds > 0 && config.defaultTaskTimeoutSeconds <= config.maxTaskTimeoutSeconds, `default ${config.defaultTaskTimeoutSeconds}s, max ${config.maxTaskTimeoutSeconds}s`);
387
- }
388
- // 13. Agent command check
389
- if (config) {
390
- const agents = config.agents || {};
475
+ return results;
476
+ },
477
+ };
478
+ const checkWatcherStaleThreshold = {
479
+ id: "watcher-stale-threshold",
480
+ description: "Watcher stale threshold is valid",
481
+ run(context) {
482
+ if (!context.config)
483
+ return [];
484
+ return [
485
+ checkResult("Watcher stale threshold is valid", context.config.watcherStaleSeconds >= 5 && context.config.watcherStaleSeconds <= 3600, `${context.config.watcherStaleSeconds}s`),
486
+ ];
487
+ },
488
+ };
489
+ const checkAllowedTestCommandsIncludesNpmTest = {
490
+ id: "allowed-test-commands-npm-test",
491
+ description: "allowedTestCommands includes npm test",
492
+ run(context) {
493
+ if (!context.config)
494
+ return [];
495
+ const hasNpmTest = context.config.allowedTestCommands.some((c) => c === "npm test" || c === "npm run test");
496
+ return [warnCheckResult("allowedTestCommands includes npm test", hasNpmTest, hasNpmTest ? "present" : "npm test is missing — add it to allowedTestCommands")];
497
+ },
498
+ };
499
+ const checkTaskTimeoutDefaults = {
500
+ id: "task-timeout-defaults",
501
+ description: "Task timeout defaults are valid",
502
+ run(context) {
503
+ if (!context.config)
504
+ return [];
505
+ return [
506
+ checkResult("Task timeout defaults are valid", context.config.defaultTaskTimeoutSeconds > 0 && context.config.defaultTaskTimeoutSeconds <= context.config.maxTaskTimeoutSeconds, `default ${context.config.defaultTaskTimeoutSeconds}s, max ${context.config.maxTaskTimeoutSeconds}s`),
507
+ ];
508
+ },
509
+ };
510
+ const checkAgentCommands = {
511
+ id: "agent-commands",
512
+ description: "Agent command check",
513
+ run(context) {
514
+ if (!context.config)
515
+ return [];
516
+ const results = [];
517
+ const agents = context.config.agents || {};
391
518
  for (const [name, agentCfg] of Object.entries(agents)) {
392
519
  const cmdName = agentCfg.command;
393
520
  const looksLikePath = isAbsolute(cmdName) || cmdName.includes("/") || cmdName.includes("\\");
394
521
  if (looksLikePath) {
395
522
  const agentExists = existsSync(cmdName);
396
- warnCheck(`Agent "${name}" command available`, agentExists, agentExists ? `Found: ${cmdName}` : `"${cmdName}" does not exist — agent tasks will fail`);
523
+ results.push(warnCheckResult(`Agent "${name}" command available`, agentExists, agentExists ? `Found: ${cmdName}` : `"${cmdName}" does not exist — agent tasks will fail`));
397
524
  continue;
398
525
  }
399
526
  // Platform-appropriate lookup: 'where' on Windows, 'command -v' on Unix
@@ -401,69 +528,171 @@ async function main() {
401
528
  const lookupCmd = isWin ? `where ${cmdName}` : `command -v ${cmdName}`;
402
529
  const fallbackCmd = isWin ? `command -v ${cmdName}` : `which ${cmdName}`;
403
530
  const found = cmd(lookupCmd) || cmd(fallbackCmd);
404
- warnCheck(`Agent "${name}" command available`, found !== "", found ? `Found: ${found.split("\n")[0]}` : `"${cmdName}" not in PATH — agent tasks will fail`);
531
+ results.push(warnCheckResult(`Agent "${name}" command available`, found !== "", found ? `Found: ${found.split("\n")[0]}` : `"${cmdName}" not in PATH — agent tasks will fail`));
405
532
  }
406
- }
407
- // 14. allowedTestCommands safety check
408
- if (config) {
409
- const testCmds = config.allowedTestCommands || [];
410
- check("allowedTestCommands is non-empty", testCmds.length > 0, testCmds.length > 0 ? `${testCmds.length} commands` : "No test commands configured");
533
+ return results;
534
+ },
535
+ };
536
+ const checkAllowedTestCommandsSafety = {
537
+ id: "allowed-test-commands-safety",
538
+ description: "allowedTestCommands safety check",
539
+ run(context) {
540
+ if (!context.config)
541
+ return [];
542
+ const results = [];
543
+ const testCmds = context.config.allowedTestCommands || [];
544
+ results.push(checkResult("allowedTestCommands is non-empty", testCmds.length > 0, testCmds.length > 0 ? `${testCmds.length} commands` : "No test commands configured"));
411
545
  const dangerous = ["rm -rf", "del /s", "format", "shutdown", "curl |", "wget |"];
412
546
  for (const cmdStr of testCmds) {
413
547
  for (const danger of dangerous) {
414
548
  if (cmdStr.toLowerCase().includes(danger)) {
415
- results.push(`[WARN] allowedTestCommands contains dangerous pattern: "${cmdStr}"`);
416
- warn++;
549
+ results.push(warnResult(`allowedTestCommands contains dangerous pattern: "${cmdStr}"`));
417
550
  }
418
551
  }
419
552
  }
420
- }
421
- // 14b. Direct profile config checks
422
- if (config) {
423
- const directCmds = config.directAllowedCommands || [];
424
- warnCheck("directAllowedCommands is non-empty", directCmds.length > 0, directCmds.length > 0 ? `${directCmds.length} commands` : "No Direct commands configured");
553
+ return results;
554
+ },
555
+ };
556
+ // Direct profile config checks
557
+ const checkDirectProfileConfig = {
558
+ id: "direct-profile-config",
559
+ description: "Direct profile config checks",
560
+ run(context) {
561
+ if (!context.config)
562
+ return [];
563
+ const results = [];
564
+ const directCmds = context.config.directAllowedCommands || [];
565
+ results.push(warnCheckResult("directAllowedCommands is non-empty", directCmds.length > 0, directCmds.length > 0 ? `${directCmds.length} commands` : "No Direct commands configured"));
425
566
  // npm run doctor should NOT be in default Direct whitelist
426
567
  const hasDoctor = directCmds.some((c) => c === "npm run doctor");
427
- check("directAllowedCommands does not include npm run doctor", !hasDoctor, hasDoctor ? "npm run doctor found in Direct whitelist — remove it for tighter security" : "not present");
428
- check("directSessionTtlSeconds is valid", config.directSessionTtlSeconds >= 60 && config.directSessionTtlSeconds <= 86400, `${config.directSessionTtlSeconds}s`);
429
- check("directMaxPatchBytes is positive", config.directMaxPatchBytes > 0, `${config.directMaxPatchBytes}`);
430
- check("directMaxFileBytes is positive", config.directMaxFileBytes > 0, `${config.directMaxFileBytes}`);
431
- }
432
- // 15. Tunnel example files check
433
- const tunnelFiles = [
434
- "examples/openai-tunnel/README.md",
435
- "examples/openai-tunnel/tunnel-client.example.yaml",
436
- "examples/openai-tunnel/chatgpt-test-prompt.md",
437
- "scripts/mcp/patchwarden-mcp-direct.cmd",
438
- "PatchWarden.cmd",
439
- "scripts/control/manage-patchwarden.ps1",
440
- "scripts/launchers/Start-PatchWarden-Direct-Tunnel.cmd",
441
- ];
442
- for (const tf of tunnelFiles) {
443
- const full = resolve(process.cwd(), tf);
444
- const exists = existsSync(full);
445
- check(`Tunnel example: ${tf}`, exists, exists ? "present" : "missing");
446
- // Check for leaked secrets in example files
447
- if (exists) {
448
- const content = readFileSync(full, "utf-8");
449
- // Only flag actual key-value assignments, not comments/mentions
450
- const leaked = /(?:api_key|sk-[a-zA-Z0-9]{10,}|token\s*[:=]\s*\S{4,}|secret\s*[:=]\s*\S{4,}|password\s*[:=]\s*\S{4,})/gi.test(
451
- // Strip comment lines first
452
- content.split("\n").filter(l => !l.trim().startsWith("#") && !l.trim().startsWith("//")).join("\n"));
453
- if (leaked) {
454
- results.push(`[WARN] ${tf} may contain secrets`);
455
- warn++;
456
- }
457
- else {
458
- results.push(`[OK] ${tf} no real secrets`);
459
- ok++;
568
+ results.push(checkResult("directAllowedCommands does not include npm run doctor", !hasDoctor, hasDoctor ? "npm run doctor found in Direct whitelist — remove it for tighter security" : "not present"));
569
+ results.push(checkResult("directSessionTtlSeconds is valid", context.config.directSessionTtlSeconds >= 60 && context.config.directSessionTtlSeconds <= 86400, `${context.config.directSessionTtlSeconds}s`));
570
+ results.push(checkResult("directMaxPatchBytes is positive", context.config.directMaxPatchBytes > 0, `${context.config.directMaxPatchBytes}`));
571
+ results.push(checkResult("directMaxFileBytes is positive", context.config.directMaxFileBytes > 0, `${context.config.directMaxFileBytes}`));
572
+ return results;
573
+ },
574
+ };
575
+ // Tunnel example files check
576
+ const checkTunnelExamples = {
577
+ id: "tunnel-examples",
578
+ description: "Tunnel example files check",
579
+ run() {
580
+ const tunnelFiles = [
581
+ "examples/openai-tunnel/README.md",
582
+ "examples/openai-tunnel/tunnel-client.example.yaml",
583
+ "examples/openai-tunnel/chatgpt-test-prompt.md",
584
+ "scripts/mcp/patchwarden-mcp-direct.cmd",
585
+ "PatchWarden.cmd",
586
+ "scripts/control/manage-patchwarden.ps1",
587
+ "scripts/launchers/Start-PatchWarden-Direct-Tunnel.cmd",
588
+ ];
589
+ const results = [];
590
+ for (const tf of tunnelFiles) {
591
+ const full = resolve(process.cwd(), tf);
592
+ const exists = existsSync(full);
593
+ results.push(checkResult(`Tunnel example: ${tf}`, exists, exists ? "present" : "missing"));
594
+ // Check for leaked secrets in example files
595
+ if (exists) {
596
+ const content = readFileSync(full, "utf-8");
597
+ // Only flag actual key-value assignments, not comments/mentions
598
+ const leaked = /(?:api_key|sk-[a-zA-Z0-9]{10,}|token\s*[:=]\s*\S{4,}|secret\s*[:=]\s*\S{4,}|password\s*[:=]\s*\S{4,})/gi.test(
599
+ // Strip comment lines first
600
+ content.split("\n").filter(l => !l.trim().startsWith("#") && !l.trim().startsWith("//")).join("\n"));
601
+ if (leaked) {
602
+ results.push(warnResult(`${tf} may contain secrets`));
603
+ }
604
+ else {
605
+ results.push(okResult(`${tf} — no real secrets`));
606
+ }
460
607
  }
461
608
  }
609
+ return results;
610
+ },
611
+ };
612
+ // ── Check registry ─────────────────────────────────────────────────
613
+ const checks = [
614
+ checkNodeVersion,
615
+ checkNpmAvailable,
616
+ checkConfirmPackage,
617
+ checkGitAvailable,
618
+ checkConfigFile,
619
+ checkPatchwardenConfigEnv,
620
+ checkConfigParseable,
621
+ checkWorkspaceRoot,
622
+ checkPathGuard,
623
+ checkSensitiveGuard,
624
+ checkSavePlanRules,
625
+ checkTaskArtifactAllowlist,
626
+ checkServerVersion,
627
+ checkToolProfiles,
628
+ checkReleaseGate,
629
+ checkHttpPort,
630
+ checkDistFiles,
631
+ checkToolModules,
632
+ checkDirectToolModules,
633
+ checkDirectSupportModules,
634
+ checkTaskDirectoryWritable,
635
+ checkWatcherStaleThreshold,
636
+ checkAllowedTestCommandsIncludesNpmTest,
637
+ checkTaskTimeoutDefaults,
638
+ checkAgentCommands,
639
+ checkAllowedTestCommandsSafety,
640
+ checkDirectProfileConfig,
641
+ checkTunnelExamples,
642
+ ];
643
+ // ── Context preparation ────────────────────────────────────────────
644
+ function prepareContext() {
645
+ const allowDefaultConfig = process.argv.includes("--allow-default-config");
646
+ const configPaths = [
647
+ resolve(process.cwd(), "patchwarden.config.json"),
648
+ process.env.PATCHWARDEN_CONFIG || "",
649
+ ].filter(Boolean);
650
+ let configPathUsed = "";
651
+ for (const p of configPaths) {
652
+ if (existsSync(p)) {
653
+ configPathUsed = p;
654
+ break;
655
+ }
656
+ }
657
+ // Load config (may fail)
658
+ let config = null;
659
+ let configError = null;
660
+ try {
661
+ config = getConfig();
662
+ }
663
+ catch (err) {
664
+ configError = err instanceof Error ? err.message : String(err);
665
+ }
666
+ return { config, configError, allowDefaultConfig, configPathUsed };
667
+ }
668
+ // ══════════════════════════════════════════════════════════════════
669
+ // Main
670
+ // ══════════════════════════════════════════════════════════════════
671
+ async function main() {
672
+ console.log("PatchWarden Doctor\n");
673
+ const context = prepareContext();
674
+ const allResults = [];
675
+ for (const check of checks) {
676
+ try {
677
+ const results = await check.run(context);
678
+ allResults.push(...results);
679
+ }
680
+ catch (err) {
681
+ allResults.push(failResult(check.description, err instanceof Error ? err.message : String(err)));
682
+ }
683
+ }
684
+ console.log(allResults.map(formatResult).join("\n"));
685
+ let ok = 0;
686
+ let warn = 0;
687
+ let fail = 0;
688
+ for (const r of allResults) {
689
+ if (r.level === "OK")
690
+ ok++;
691
+ else if (r.level === "WARN")
692
+ warn++;
693
+ else
694
+ fail++;
462
695
  }
463
- // ══════════════════════════════════════════════════════════════════
464
- // Summary
465
- // ══════════════════════════════════════════════════════════════════
466
- console.log(results.join("\n"));
467
696
  console.log(`\n${"=".repeat(50)}`);
468
697
  console.log(`OK: ${ok} WARN: ${warn} FAIL: ${fail}`);
469
698
  console.log(`${"=".repeat(50)}`);
@@ -480,8 +709,10 @@ async function main() {
480
709
  console.log("\n✅ All checks passed.");
481
710
  process.exit(0);
482
711
  }
483
- } // end async main
712
+ }
484
713
  main().catch((err) => {
485
- console.error("Doctor crashed:", err);
714
+ logger.fatal("Doctor crashed", {
715
+ error: err instanceof Error ? err.message : String(err),
716
+ });
486
717
  process.exit(1);
487
718
  });