terramend 0.2.0

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 (406) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +145 -0
  3. package/dist/agents/claude.d.ts +73 -0
  4. package/dist/agents/claudePretoolGate.d.ts +99 -0
  5. package/dist/agents/gateServer.d.ts +7 -0
  6. package/dist/agents/index.d.ts +6 -0
  7. package/dist/agents/nativeFsDenies.d.ts +28 -0
  8. package/dist/agents/opencode.d.ts +231 -0
  9. package/dist/agents/opencodePlugin.d.ts +85 -0
  10. package/dist/agents/opencodeShared.d.ts +40 -0
  11. package/dist/agents/postRun.d.ts +132 -0
  12. package/dist/agents/reviewer.d.ts +38 -0
  13. package/dist/agents/sessionLabeler.d.ts +97 -0
  14. package/dist/agents/shared.d.ts +189 -0
  15. package/dist/agents/subagentModels.d.ts +19 -0
  16. package/dist/agents/subagentToolGates.d.ts +55 -0
  17. package/dist/cli.mjs +197426 -0
  18. package/dist/external.d.ts +227 -0
  19. package/dist/index.d.ts +6 -0
  20. package/dist/index.js +196783 -0
  21. package/dist/internal/index.d.ts +18 -0
  22. package/dist/internal.js +1714 -0
  23. package/dist/lifecycle.d.ts +2 -0
  24. package/dist/main.d.ts +8 -0
  25. package/dist/mcp/arkConfig.d.ts +1 -0
  26. package/dist/mcp/checkSuite.d.ts +25 -0
  27. package/dist/mcp/checkout.d.ts +77 -0
  28. package/dist/mcp/comment.d.ts +119 -0
  29. package/dist/mcp/commitInfo.d.ts +9 -0
  30. package/dist/mcp/crosswalk.d.ts +105 -0
  31. package/dist/mcp/dependencies.d.ts +8 -0
  32. package/dist/mcp/geminiSanitizer.d.ts +28 -0
  33. package/dist/mcp/git.d.ts +46 -0
  34. package/dist/mcp/guardrails.d.ts +104 -0
  35. package/dist/mcp/issue.d.ts +18 -0
  36. package/dist/mcp/issueComments.d.ts +9 -0
  37. package/dist/mcp/issueEvents.d.ts +9 -0
  38. package/dist/mcp/issueInfo.d.ts +9 -0
  39. package/dist/mcp/labels.d.ts +12 -0
  40. package/dist/mcp/localContext.d.ts +19 -0
  41. package/dist/mcp/moduleExtraction.d.ts +71 -0
  42. package/dist/mcp/moduleTests.d.ts +104 -0
  43. package/dist/mcp/modules.d.ts +179 -0
  44. package/dist/mcp/output.d.ts +12 -0
  45. package/dist/mcp/pathSafety.d.ts +14 -0
  46. package/dist/mcp/policy.d.ts +48 -0
  47. package/dist/mcp/pr.d.ts +49 -0
  48. package/dist/mcp/prInfo.d.ts +9 -0
  49. package/dist/mcp/providerSchema.d.ts +50 -0
  50. package/dist/mcp/review.d.ts +199 -0
  51. package/dist/mcp/reviewComments.d.ts +178 -0
  52. package/dist/mcp/roots.d.ts +58 -0
  53. package/dist/mcp/scope.d.ts +15 -0
  54. package/dist/mcp/selectMode.d.ts +18 -0
  55. package/dist/mcp/server.d.ts +48 -0
  56. package/dist/mcp/shared.d.ts +47 -0
  57. package/dist/mcp/shell.d.ts +37 -0
  58. package/dist/mcp/staleFix.d.ts +51 -0
  59. package/dist/mcp/terraform/cost.d.ts +55 -0
  60. package/dist/mcp/terraform/currency.d.ts +94 -0
  61. package/dist/mcp/terraform/decisions.d.ts +178 -0
  62. package/dist/mcp/terraform/findings.d.ts +75 -0
  63. package/dist/mcp/terraform/plan.d.ts +157 -0
  64. package/dist/mcp/terraform/scanners.d.ts +131 -0
  65. package/dist/mcp/terraform/tools.d.ts +63 -0
  66. package/dist/mcp/terraform/types.d.ts +172 -0
  67. package/dist/mcp/terraform.d.ts +22 -0
  68. package/dist/mcp/terratest.d.ts +83 -0
  69. package/dist/mcp/upload.d.ts +6 -0
  70. package/dist/models.d.ts +171 -0
  71. package/dist/modes.d.ts +26 -0
  72. package/dist/prep/index.d.ts +7 -0
  73. package/dist/prep/installNodeDependencies.d.ts +2 -0
  74. package/dist/prep/installPythonDependencies.d.ts +2 -0
  75. package/dist/prep/types.d.ts +31 -0
  76. package/dist/reviewQuality.d.ts +64 -0
  77. package/dist/skills/terraform-best-practices/SKILL.md +369 -0
  78. package/dist/toolState.d.ts +135 -0
  79. package/dist/utils/activity.d.ts +40 -0
  80. package/dist/utils/agent.d.ts +20 -0
  81. package/dist/utils/agentHangReport.d.ts +38 -0
  82. package/dist/utils/apiFetch.d.ts +19 -0
  83. package/dist/utils/apiKeys.d.ts +41 -0
  84. package/dist/utils/apiUrl.d.ts +20 -0
  85. package/dist/utils/assets.d.ts +8 -0
  86. package/dist/utils/billingErrors.d.ts +85 -0
  87. package/dist/utils/body.d.ts +34 -0
  88. package/dist/utils/buildTerramendFooter.d.ts +25 -0
  89. package/dist/utils/byokFallback.d.ts +85 -0
  90. package/dist/utils/claudeSubscription.d.ts +30 -0
  91. package/dist/utils/cli.d.ts +10 -0
  92. package/dist/utils/codexHome.d.ts +29 -0
  93. package/dist/utils/codexOAuth.d.ts +60 -0
  94. package/dist/utils/diffCoverage.d.ts +63 -0
  95. package/dist/utils/errorReport.d.ts +17 -0
  96. package/dist/utils/exitHandler.d.ts +8 -0
  97. package/dist/utils/fixDoubleEscapedString.d.ts +1 -0
  98. package/dist/utils/gitAuth.d.ts +84 -0
  99. package/dist/utils/gitAuthServer.d.ts +24 -0
  100. package/dist/utils/github.d.ts +78 -0
  101. package/dist/utils/globals.d.ts +3 -0
  102. package/dist/utils/install.d.ts +60 -0
  103. package/dist/utils/instructions.d.ts +48 -0
  104. package/dist/utils/leapingComment.d.ts +11 -0
  105. package/dist/utils/learnings.d.ts +62 -0
  106. package/dist/utils/learningsTruncate.d.ts +25 -0
  107. package/dist/utils/lifecycle.d.ts +57 -0
  108. package/dist/utils/log.d.ts +111 -0
  109. package/dist/utils/normalizeEnv.d.ts +30 -0
  110. package/dist/utils/openCodeModels.d.ts +11 -0
  111. package/dist/utils/overrides.d.ts +40 -0
  112. package/dist/utils/packageManager.d.ts +49 -0
  113. package/dist/utils/patchWorkflowRunFields.d.ts +29 -0
  114. package/dist/utils/payload.d.ts +105 -0
  115. package/dist/utils/prSummary.d.ts +61 -0
  116. package/dist/utils/progressComment.d.ts +146 -0
  117. package/dist/utils/providerErrors.d.ts +31 -0
  118. package/dist/utils/rangeDiff.d.ts +51 -0
  119. package/dist/utils/remediationCommand.d.ts +55 -0
  120. package/dist/utils/retry.d.ts +13 -0
  121. package/dist/utils/reviewCleanup.d.ts +14 -0
  122. package/dist/utils/run.d.ts +9 -0
  123. package/dist/utils/runContext.d.ts +60 -0
  124. package/dist/utils/runContextData.d.ts +23 -0
  125. package/dist/utils/runErrorRenderer.d.ts +64 -0
  126. package/dist/utils/runLifecycle.d.ts +86 -0
  127. package/dist/utils/runStartupLog.d.ts +15 -0
  128. package/dist/utils/secrets.d.ts +22 -0
  129. package/dist/utils/setup.d.ts +90 -0
  130. package/dist/utils/shell.d.ts +32 -0
  131. package/dist/utils/skills.d.ts +10 -0
  132. package/dist/utils/subprocess.d.ts +80 -0
  133. package/dist/utils/terraformMcp.d.ts +42 -0
  134. package/dist/utils/time.d.ts +15 -0
  135. package/dist/utils/timer.d.ts +23 -0
  136. package/dist/utils/todoTracking.d.ts +16 -0
  137. package/dist/utils/token.d.ts +39 -0
  138. package/dist/utils/version.d.ts +2 -0
  139. package/dist/utils/versioning.d.ts +7 -0
  140. package/dist/utils/vertex.d.ts +16 -0
  141. package/dist/utils/workflow.d.ts +13 -0
  142. package/package.json +119 -0
  143. package/src/agents/claude.test.ts +1016 -0
  144. package/src/agents/claude.ts +1246 -0
  145. package/src/agents/claudePretoolGate.test.ts +28 -0
  146. package/src/agents/claudePretoolGate.ts +173 -0
  147. package/src/agents/gateServer.test.ts +204 -0
  148. package/src/agents/gateServer.ts +124 -0
  149. package/src/agents/index.ts +10 -0
  150. package/src/agents/nativeFsDenies.ts +82 -0
  151. package/src/agents/opencode.test.ts +1440 -0
  152. package/src/agents/opencode.ts +1312 -0
  153. package/src/agents/opencodePlugin.ts +222 -0
  154. package/src/agents/opencodeShared.test.ts +34 -0
  155. package/src/agents/opencodeShared.ts +121 -0
  156. package/src/agents/postRun.test.ts +549 -0
  157. package/src/agents/postRun.ts +535 -0
  158. package/src/agents/reviewer.ts +104 -0
  159. package/src/agents/sessionLabeler.test.ts +247 -0
  160. package/src/agents/sessionLabeler.ts +178 -0
  161. package/src/agents/shared.test.ts +76 -0
  162. package/src/agents/shared.ts +292 -0
  163. package/src/agents/subagentModels.test.ts +113 -0
  164. package/src/agents/subagentModels.ts +40 -0
  165. package/src/agents/subagentRegistration.test.ts +41 -0
  166. package/src/agents/subagentToolGates.ts +114 -0
  167. package/src/cli.test.ts +129 -0
  168. package/src/cli.ts +105 -0
  169. package/src/commands/gha.test.ts +192 -0
  170. package/src/commands/gha.ts +188 -0
  171. package/src/commands/mcp.ts +122 -0
  172. package/src/config.ts +1 -0
  173. package/src/entry.ts +7 -0
  174. package/src/entryPost.stdlibOnly.test.ts +109 -0
  175. package/src/entryPost.ts +99 -0
  176. package/src/external.test.ts +16 -0
  177. package/src/external.ts +302 -0
  178. package/src/index.ts +11 -0
  179. package/src/internal/index.ts +71 -0
  180. package/src/lifecycle.ts +2 -0
  181. package/src/main.test.ts +873 -0
  182. package/src/main.ts +712 -0
  183. package/src/mcp/__fixtures__/terramend-scratch-pr-49-review-3485940013.json +110 -0
  184. package/src/mcp/__fixtures__/terramend-scratch-pr-64-review-3531000326.json +14 -0
  185. package/src/mcp/__fixtures__/terramend-test-repo-pr-1.diff.json +67 -0
  186. package/src/mcp/__snapshots__/checkout.test.ts.snap +109 -0
  187. package/src/mcp/__snapshots__/reviewComments.test.ts.snap +71 -0
  188. package/src/mcp/arkConfig.ts +7 -0
  189. package/src/mcp/checkSuite.test.ts +245 -0
  190. package/src/mcp/checkSuite.ts +255 -0
  191. package/src/mcp/checkout.test.ts +752 -0
  192. package/src/mcp/checkout.ts +886 -0
  193. package/src/mcp/comment.test.ts +772 -0
  194. package/src/mcp/comment.ts +582 -0
  195. package/src/mcp/commitInfo.test.ts +127 -0
  196. package/src/mcp/commitInfo.ts +61 -0
  197. package/src/mcp/crosswalk.test.ts +106 -0
  198. package/src/mcp/crosswalk.ts +339 -0
  199. package/src/mcp/dependencies.test.ts +309 -0
  200. package/src/mcp/dependencies.ts +189 -0
  201. package/src/mcp/geminiSanitizer.test.ts +287 -0
  202. package/src/mcp/geminiSanitizer.ts +207 -0
  203. package/src/mcp/git.test.ts +1083 -0
  204. package/src/mcp/git.ts +890 -0
  205. package/src/mcp/guardrails.test.ts +705 -0
  206. package/src/mcp/guardrails.ts +465 -0
  207. package/src/mcp/issue.test.ts +113 -0
  208. package/src/mcp/issue.ts +73 -0
  209. package/src/mcp/issueComments.test.ts +69 -0
  210. package/src/mcp/issueComments.ts +48 -0
  211. package/src/mcp/issueEvents.test.ts +134 -0
  212. package/src/mcp/issueEvents.ts +100 -0
  213. package/src/mcp/issueInfo.test.ts +104 -0
  214. package/src/mcp/issueInfo.ts +72 -0
  215. package/src/mcp/labels.test.ts +52 -0
  216. package/src/mcp/labels.ts +34 -0
  217. package/src/mcp/localContext.ts +28 -0
  218. package/src/mcp/localServer.test.ts +75 -0
  219. package/src/mcp/localServer.ts +131 -0
  220. package/src/mcp/moduleExtraction.test.ts +261 -0
  221. package/src/mcp/moduleExtraction.ts +313 -0
  222. package/src/mcp/moduleTests.test.ts +269 -0
  223. package/src/mcp/moduleTests.ts +421 -0
  224. package/src/mcp/modules.test.ts +640 -0
  225. package/src/mcp/modules.ts +696 -0
  226. package/src/mcp/output.test.ts +96 -0
  227. package/src/mcp/output.ts +70 -0
  228. package/src/mcp/pathSafety.test.ts +44 -0
  229. package/src/mcp/pathSafety.ts +28 -0
  230. package/src/mcp/policy.test.ts +282 -0
  231. package/src/mcp/policy.ts +199 -0
  232. package/src/mcp/pr.test.ts +387 -0
  233. package/src/mcp/pr.ts +194 -0
  234. package/src/mcp/prInfo.test.ts +96 -0
  235. package/src/mcp/prInfo.ts +91 -0
  236. package/src/mcp/providerSchema.test.ts +85 -0
  237. package/src/mcp/providerSchema.ts +175 -0
  238. package/src/mcp/review.test.ts +936 -0
  239. package/src/mcp/review.ts +923 -0
  240. package/src/mcp/reviewComments.test.ts +549 -0
  241. package/src/mcp/reviewComments.ts +896 -0
  242. package/src/mcp/roots.test.ts +175 -0
  243. package/src/mcp/roots.ts +217 -0
  244. package/src/mcp/scope.test.ts +59 -0
  245. package/src/mcp/scope.ts +65 -0
  246. package/src/mcp/security.test.ts +720 -0
  247. package/src/mcp/selectMode.test.ts +210 -0
  248. package/src/mcp/selectMode.ts +181 -0
  249. package/src/mcp/server.test.ts +292 -0
  250. package/src/mcp/server.ts +403 -0
  251. package/src/mcp/shared.ts +100 -0
  252. package/src/mcp/shell.test.ts +520 -0
  253. package/src/mcp/shell.ts +505 -0
  254. package/src/mcp/staleFix.test.ts +237 -0
  255. package/src/mcp/staleFix.ts +277 -0
  256. package/src/mcp/terraform/cost.ts +163 -0
  257. package/src/mcp/terraform/currency.test.ts +338 -0
  258. package/src/mcp/terraform/currency.ts +336 -0
  259. package/src/mcp/terraform/decisions.ts +527 -0
  260. package/src/mcp/terraform/findings.ts +333 -0
  261. package/src/mcp/terraform/plan.ts +348 -0
  262. package/src/mcp/terraform/scanners.ts +809 -0
  263. package/src/mcp/terraform/tools.test.ts +1071 -0
  264. package/src/mcp/terraform/tools.ts +908 -0
  265. package/src/mcp/terraform/types.ts +305 -0
  266. package/src/mcp/terraform.test.ts +1957 -0
  267. package/src/mcp/terraform.ts +23 -0
  268. package/src/mcp/terratest.test.ts +105 -0
  269. package/src/mcp/terratest.ts +196 -0
  270. package/src/mcp/toolFiltering.test.ts +85 -0
  271. package/src/mcp/upload.test.ts +180 -0
  272. package/src/mcp/upload.ts +112 -0
  273. package/src/models.test.ts +300 -0
  274. package/src/models.ts +708 -0
  275. package/src/modes.test.ts +107 -0
  276. package/src/modes.ts +880 -0
  277. package/src/prep/index.ts +43 -0
  278. package/src/prep/installNodeDependencies.test.ts +298 -0
  279. package/src/prep/installNodeDependencies.ts +196 -0
  280. package/src/prep/installPythonDependencies.test.ts +268 -0
  281. package/src/prep/installPythonDependencies.ts +199 -0
  282. package/src/prep/types.ts +38 -0
  283. package/src/reviewQuality.test.ts +63 -0
  284. package/src/reviewQuality.ts +134 -0
  285. package/src/runCli.test.ts +214 -0
  286. package/src/runCli.ts +282 -0
  287. package/src/skills/terraform-best-practices/SKILL.md +369 -0
  288. package/src/toolState.test.ts +45 -0
  289. package/src/toolState.ts +252 -0
  290. package/src/utils/activity.test.ts +188 -0
  291. package/src/utils/activity.ts +210 -0
  292. package/src/utils/agent.test.ts +251 -0
  293. package/src/utils/agent.ts +139 -0
  294. package/src/utils/agentHangReport.test.ts +203 -0
  295. package/src/utils/agentHangReport.ts +170 -0
  296. package/src/utils/apiFetch.test.ts +115 -0
  297. package/src/utils/apiFetch.ts +62 -0
  298. package/src/utils/apiKeys.test.ts +344 -0
  299. package/src/utils/apiKeys.ts +206 -0
  300. package/src/utils/apiUrl.test.ts +30 -0
  301. package/src/utils/apiUrl.ts +59 -0
  302. package/src/utils/assets.test.ts +153 -0
  303. package/src/utils/assets.ts +107 -0
  304. package/src/utils/billingErrors.test.ts +121 -0
  305. package/src/utils/billingErrors.ts +189 -0
  306. package/src/utils/body.test.ts +217 -0
  307. package/src/utils/body.ts +168 -0
  308. package/src/utils/buildTerramendFooter.test.ts +38 -0
  309. package/src/utils/buildTerramendFooter.ts +82 -0
  310. package/src/utils/byokFallback.test.ts +205 -0
  311. package/src/utils/byokFallback.ts +128 -0
  312. package/src/utils/claudeSubscription.test.ts +179 -0
  313. package/src/utils/claudeSubscription.ts +93 -0
  314. package/src/utils/cli.ts +31 -0
  315. package/src/utils/codexHome.test.ts +190 -0
  316. package/src/utils/codexHome.ts +191 -0
  317. package/src/utils/codexOAuth.ts +147 -0
  318. package/src/utils/codexRefreshDetect.test.ts +85 -0
  319. package/src/utils/codexRefreshDetect.ts +35 -0
  320. package/src/utils/diffCoverage.test.ts +468 -0
  321. package/src/utils/diffCoverage.ts +404 -0
  322. package/src/utils/errorReport.test.ts +135 -0
  323. package/src/utils/errorReport.ts +83 -0
  324. package/src/utils/exitHandler.ts +35 -0
  325. package/src/utils/fixDoubleEscapedString.ts +9 -0
  326. package/src/utils/ghaCore.ts +13 -0
  327. package/src/utils/gitAuth.test.ts +322 -0
  328. package/src/utils/gitAuth.ts +263 -0
  329. package/src/utils/gitAuthServer.test.ts +260 -0
  330. package/src/utils/gitAuthServer.ts +182 -0
  331. package/src/utils/github.test.ts +615 -0
  332. package/src/utils/github.ts +538 -0
  333. package/src/utils/globals.ts +9 -0
  334. package/src/utils/humanEditCapture.test.ts +100 -0
  335. package/src/utils/humanEditCapture.ts +193 -0
  336. package/src/utils/install.test.ts +768 -0
  337. package/src/utils/install.ts +492 -0
  338. package/src/utils/instructions.test.ts +240 -0
  339. package/src/utils/instructions.ts +543 -0
  340. package/src/utils/leapingComment.test.ts +51 -0
  341. package/src/utils/leapingComment.ts +18 -0
  342. package/src/utils/learnings.test.ts +87 -0
  343. package/src/utils/learnings.ts +138 -0
  344. package/src/utils/learningsTocRender.test.ts +116 -0
  345. package/src/utils/learningsTruncate.test.ts +39 -0
  346. package/src/utils/learningsTruncate.ts +42 -0
  347. package/src/utils/lifecycle.test.ts +195 -0
  348. package/src/utils/lifecycle.ts +198 -0
  349. package/src/utils/log.test.ts +402 -0
  350. package/src/utils/log.ts +432 -0
  351. package/src/utils/normalizeEnv.test.ts +91 -0
  352. package/src/utils/normalizeEnv.ts +106 -0
  353. package/src/utils/openCodeModels.ts +82 -0
  354. package/src/utils/overrides.test.ts +89 -0
  355. package/src/utils/overrides.ts +98 -0
  356. package/src/utils/packageManager.test.ts +321 -0
  357. package/src/utils/packageManager.ts +257 -0
  358. package/src/utils/patchWorkflowRunFields.test.ts +92 -0
  359. package/src/utils/patchWorkflowRunFields.ts +150 -0
  360. package/src/utils/payload.test.ts +497 -0
  361. package/src/utils/payload.ts +371 -0
  362. package/src/utils/postApiFetch.ts +51 -0
  363. package/src/utils/prSummary.test.ts +224 -0
  364. package/src/utils/prSummary.ts +147 -0
  365. package/src/utils/progressComment.ts +261 -0
  366. package/src/utils/providerErrors.test.ts +315 -0
  367. package/src/utils/providerErrors.ts +172 -0
  368. package/src/utils/rangeDiff.test.ts +236 -0
  369. package/src/utils/rangeDiff.ts +182 -0
  370. package/src/utils/remediationCommand.test.ts +163 -0
  371. package/src/utils/remediationCommand.ts +119 -0
  372. package/src/utils/retry.test.ts +153 -0
  373. package/src/utils/retry.ts +58 -0
  374. package/src/utils/reviewCleanup.ts +106 -0
  375. package/src/utils/run.ts +99 -0
  376. package/src/utils/runContext.ts +145 -0
  377. package/src/utils/runContextData.ts +58 -0
  378. package/src/utils/runErrorRenderer.test.ts +95 -0
  379. package/src/utils/runErrorRenderer.ts +259 -0
  380. package/src/utils/runFixture.ts +76 -0
  381. package/src/utils/runLifecycle.ts +237 -0
  382. package/src/utils/runStartupLog.ts +60 -0
  383. package/src/utils/secrets.test.ts +103 -0
  384. package/src/utils/secrets.ts +177 -0
  385. package/src/utils/setup.test.ts +509 -0
  386. package/src/utils/setup.ts +352 -0
  387. package/src/utils/shell.ts +103 -0
  388. package/src/utils/skills.test.ts +46 -0
  389. package/src/utils/skills.ts +67 -0
  390. package/src/utils/subprocess.test.ts +170 -0
  391. package/src/utils/subprocess.ts +438 -0
  392. package/src/utils/terraformMcp.test.ts +63 -0
  393. package/src/utils/terraformMcp.ts +83 -0
  394. package/src/utils/time.test.ts +105 -0
  395. package/src/utils/time.ts +59 -0
  396. package/src/utils/timer.test.ts +91 -0
  397. package/src/utils/timer.ts +72 -0
  398. package/src/utils/todoTracking.test.ts +223 -0
  399. package/src/utils/todoTracking.ts +167 -0
  400. package/src/utils/token.test.ts +239 -0
  401. package/src/utils/token.ts +186 -0
  402. package/src/utils/version.ts +10 -0
  403. package/src/utils/versioning.test.ts +34 -0
  404. package/src/utils/versioning.ts +44 -0
  405. package/src/utils/vertex.ts +85 -0
  406. package/src/utils/workflow.ts +25 -0
@@ -0,0 +1,1016 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest";
6
+ import {
7
+ buildAgentsJson,
8
+ buildManagedSettings,
9
+ buildStopHookScript,
10
+ CLAUDE_EXEC_TOOL_DENY_RULES,
11
+ claude,
12
+ runClaude,
13
+ stripProviderPrefix,
14
+ tailLines,
15
+ writeMcpConfig,
16
+ } from "#app/agents/claude";
17
+ import { startGateServer } from "#app/agents/gateServer";
18
+ import { finalizeAgentResult } from "#app/agents/postRun";
19
+ import { REVIEWER_AGENT_NAME } from "#app/agents/reviewer";
20
+ import type { AgentRunContext } from "#app/agents/shared";
21
+ import type { ToolState } from "#app/toolState";
22
+ import { preflightClaudeSubscription } from "#app/utils/claudeSubscription";
23
+ import { installFromNpmTarball } from "#app/utils/install";
24
+ import { installBundledSkills } from "#app/utils/skills";
25
+ import {
26
+ SPAWN_ACTIVITY_TIMEOUT_CODE,
27
+ type SpawnOptions,
28
+ SpawnTimeoutError,
29
+ spawn,
30
+ } from "#app/utils/subprocess";
31
+ import type { TodoTracker } from "#app/utils/todoTracking";
32
+
33
+ // installManagedSettings shells out to sudo in CI — fake execFileSync so the
34
+ // CI=true path can be exercised without privileges (or a Linux host).
35
+ vi.mock("node:child_process", async (importOriginal) => {
36
+ const actual = await importOriginal<typeof import("node:child_process")>();
37
+ return { ...actual, execFileSync: vi.fn() };
38
+ });
39
+ // the harness shells out via spawn() — fake it so tests stay subprocess-free
40
+ // and we can feed scripted NDJSON event streams. error classes stay real.
41
+ vi.mock("#app/utils/subprocess", async (importOriginal) => {
42
+ const actual = await importOriginal<typeof import("#app/utils/subprocess")>();
43
+ return { ...actual, spawn: vi.fn() };
44
+ });
45
+ // CLI install hits the npm registry — never in unit tests.
46
+ vi.mock("#app/utils/install", () => ({
47
+ installFromNpmTarball: vi.fn(async () => "/fake/bin/claude.exe"),
48
+ }));
49
+ // gate server binds a real HTTP port — replace with an inert handle.
50
+ vi.mock("#app/agents/gateServer", () => ({ startGateServer: vi.fn() }));
51
+ // subscription preflight makes a live Anthropic API call.
52
+ vi.mock("#app/utils/claudeSubscription", () => ({ preflightClaudeSubscription: vi.fn() }));
53
+ // finalizeAgentResult shells out to git via collectPostRunIssues — passthrough.
54
+ vi.mock("#app/agents/postRun", () => ({
55
+ finalizeAgentResult: vi.fn(async (params: { result: unknown }) => params.result),
56
+ }));
57
+ // skills install shells out to npx.
58
+ vi.mock("#app/utils/skills", () => ({ installBundledSkills: vi.fn() }));
59
+ // terraform-mcp-server resolution probes for docker — pin it per test via the
60
+ // mutable state (a plain closure, so mock resets can't wipe the default).
61
+ const terraformMcpState = vi.hoisted(() => ({
62
+ resolution: { kind: "disabled" } as { kind: string; command?: string; args?: string[] },
63
+ }));
64
+ vi.mock("#app/utils/terraformMcp", async (importOriginal) => {
65
+ const actual = await importOriginal<typeof import("#app/utils/terraformMcp")>();
66
+ return { ...actual, resolveTerraformMcp: () => terraformMcpState.resolution };
67
+ });
68
+
69
+ const execFileSyncMock = vi.mocked(execFileSync);
70
+ const spawnMock = vi.mocked(spawn);
71
+ const startGateServerMock = vi.mocked(startGateServer);
72
+ const preflightMock = vi.mocked(preflightClaudeSubscription);
73
+ const finalizeMock = vi.mocked(finalizeAgentResult);
74
+ const installMock = vi.mocked(installFromNpmTarball);
75
+ const installBundledSkillsMock = vi.mocked(installBundledSkills);
76
+
77
+ function makeCtx(secretDenyPaths?: string[]): AgentRunContext {
78
+ // buildManagedSettings only reads ctx.secretDenyPaths.
79
+ return { secretDenyPaths } as unknown as AgentRunContext;
80
+ }
81
+
82
+ function buildSettings(params?: { secretDenyPaths?: string[]; stopHookPath?: string | null }) {
83
+ return buildManagedSettings({
84
+ ctx: makeCtx(params?.secretDenyPaths),
85
+ stopHookPath: params?.stopHookPath ?? null,
86
+ pretoolGateScriptPath: "/tmp/run/terramend-pretool-gate.mjs",
87
+ });
88
+ }
89
+
90
+ function denyRules(settings: Record<string, unknown>): string[] {
91
+ const permissions = settings.permissions as { deny: string[] };
92
+ return permissions.deny;
93
+ }
94
+
95
+ describe("writeMcpConfig", () => {
96
+ function writeConfigIn(dir: string): Record<string, Record<string, unknown>> {
97
+ const ctx = {
98
+ tmpdir: dir,
99
+ mcpServerUrl: "http://127.0.0.1:7777/mcp",
100
+ payload: { terraformMcp: false },
101
+ } as unknown as AgentRunContext;
102
+ const path = writeMcpConfig(ctx);
103
+ return (
104
+ JSON.parse(readFileSync(path, "utf8")) as {
105
+ mcpServers: Record<string, Record<string, unknown>>;
106
+ }
107
+ ).mcpServers;
108
+ }
109
+
110
+ it("writes only the terramend HTTP server by default", () => {
111
+ const dir = mkdtempSync(join(tmpdir(), "terramend-mcpconf-"));
112
+ try {
113
+ const servers = writeConfigIn(dir);
114
+ expect(servers).toEqual({
115
+ terramend: {
116
+ type: "http",
117
+ url: "http://127.0.0.1:7777/mcp",
118
+ // env-expansion placeholder — Claude Code resolves ${VAR} from its env.
119
+ headers: { Authorization: "Bearer ${TERRAMEND_MCP_TOKEN}" },
120
+ },
121
+ });
122
+ } finally {
123
+ rmSync(dir, { recursive: true, force: true });
124
+ }
125
+ });
126
+
127
+ it("adds the terraform stdio server when terraform-mcp resolves available (P2.2)", () => {
128
+ const dir = mkdtempSync(join(tmpdir(), "terramend-mcpconf-"));
129
+ terraformMcpState.resolution = {
130
+ kind: "available",
131
+ command: "docker",
132
+ args: ["run", "-i", "--rm", "hashicorp/terraform-mcp-server:0.5.2", "--toolsets=registry"],
133
+ };
134
+ try {
135
+ const servers = writeConfigIn(dir);
136
+ expect(servers.terraform).toEqual({
137
+ type: "stdio",
138
+ command: "docker",
139
+ args: ["run", "-i", "--rm", "hashicorp/terraform-mcp-server:0.5.2", "--toolsets=registry"],
140
+ });
141
+ expect(servers.terramend).toEqual({
142
+ type: "http",
143
+ url: "http://127.0.0.1:7777/mcp",
144
+ headers: { Authorization: "Bearer ${TERRAMEND_MCP_TOKEN}" },
145
+ });
146
+ } finally {
147
+ terraformMcpState.resolution = { kind: "disabled" };
148
+ rmSync(dir, { recursive: true, force: true });
149
+ }
150
+ });
151
+ });
152
+
153
+ describe("CLAUDE_EXEC_TOOL_DENY_RULES", () => {
154
+ it("covers every native exec tool at top level and inside Agent(...) subagents", () => {
155
+ expect([...CLAUDE_EXEC_TOOL_DENY_RULES].sort()).toEqual(
156
+ [
157
+ "Bash",
158
+ "Monitor",
159
+ "REPL",
160
+ "Workflow",
161
+ "Agent(Bash)",
162
+ "Agent(Monitor)",
163
+ "Agent(REPL)",
164
+ "Agent(Workflow)",
165
+ ].sort(),
166
+ );
167
+ });
168
+ });
169
+
170
+ describe("buildManagedSettings", () => {
171
+ it("denies the native exec tools in permissions.deny (bypass-immune layer)", () => {
172
+ // regression lock for the --dangerously-skip-permissions leak: the
173
+ // cliArg-source --disallowedTools deny alone was bypassable, so every
174
+ // exec tool MUST also appear in the managed-settings deny.
175
+ const deny = denyRules(buildSettings());
176
+ for (const rule of ["Bash", "Monitor", "REPL", "Workflow"]) {
177
+ expect(deny).toContain(rule);
178
+ expect(deny).toContain(`Agent(${rule})`);
179
+ }
180
+ });
181
+
182
+ it("denies /proc and /sys for every native FS tool", () => {
183
+ const deny = denyRules(buildSettings());
184
+ for (const tool of ["Read", "Grep", "Edit", "Glob"]) {
185
+ expect(deny).toContain(`${tool}(//proc/**)`);
186
+ expect(deny).toContain(`${tool}(//sys/**)`);
187
+ }
188
+ });
189
+
190
+ it("carries the blanket .git write deny and the narrow .git/config read deny", () => {
191
+ const deny = denyRules(buildSettings());
192
+ expect(deny).toContain("Edit(.git)");
193
+ expect(deny).toContain("Edit(.git/**)");
194
+ expect(deny).toContain("Edit(**/.git)");
195
+ expect(deny).toContain("Edit(**/.git/**)");
196
+ for (const tool of ["Read", "Grep", "Glob"]) {
197
+ expect(deny).toContain(`${tool}(.git/config)`);
198
+ }
199
+ });
200
+
201
+ it("maps every secretDenyPath onto all four FS tools and the bash sandbox", () => {
202
+ const path = "/var/lib/terramend/opencode";
203
+ const settings = buildSettings({ secretDenyPaths: [path] });
204
+ const deny = denyRules(settings);
205
+ for (const tool of ["Read", "Grep", "Edit", "Glob"]) {
206
+ expect(deny).toContain(`${tool}(${path}/**)`);
207
+ expect(deny).toContain(`${tool}(/${path}/**)`);
208
+ }
209
+ const sandbox = settings.sandbox as { filesystem: { denyRead: string[] } };
210
+ expect(sandbox.filesystem.denyRead).toEqual(["/proc", "/sys", path]);
211
+ });
212
+
213
+ it("locks managed-only permission rules and hooks", () => {
214
+ const settings = buildSettings();
215
+ expect(settings.allowManagedPermissionRulesOnly).toBe(true);
216
+ expect(settings.allowManagedHooksOnly).toBe(true);
217
+ });
218
+
219
+ it("registers the PreToolUse gate against terramend MCP tools", () => {
220
+ const settings = buildSettings();
221
+ const hooks = settings.hooks as {
222
+ PreToolUse: Array<{ matcher: string; hooks: Array<{ type: string; command: string }> }>;
223
+ Stop?: unknown;
224
+ };
225
+ expect(hooks.PreToolUse).toHaveLength(1);
226
+ expect(hooks.PreToolUse[0]?.matcher).toBe("^mcp__terramend__");
227
+ expect(hooks.PreToolUse[0]?.hooks[0]?.command).toContain("terramend-pretool-gate.mjs");
228
+ expect(hooks.Stop).toBeUndefined();
229
+ });
230
+
231
+ it("layers the Stop hook in when a stop hook path is provided", () => {
232
+ const settings = buildSettings({ stopHookPath: "/tmp/run/terramend-stop-hook.sh" });
233
+ const hooks = settings.hooks as {
234
+ Stop: Array<{ hooks: Array<{ type: string; command: string }> }>;
235
+ };
236
+ expect(hooks.Stop).toEqual([
237
+ { hooks: [{ type: "command", command: "/tmp/run/terramend-stop-hook.sh" }] },
238
+ ]);
239
+ });
240
+ });
241
+
242
+ describe("buildAgentsJson", () => {
243
+ it("defines the reviewer subagent on the cheaper sibling model", () => {
244
+ const agents = JSON.parse(buildAgentsJson()) as Record<
245
+ string,
246
+ { description: string; prompt: string; model: string }
247
+ >;
248
+ const reviewer = agents[REVIEWER_AGENT_NAME];
249
+ expect(reviewer).toBeDefined();
250
+ expect(reviewer?.model).toBe("claude-sonnet-4-6");
251
+ expect(reviewer?.description).toContain("Read-only");
252
+ expect(reviewer?.prompt.length ?? 0).toBeGreaterThan(0);
253
+ });
254
+ });
255
+
256
+ describe("stripProviderPrefix", () => {
257
+ it("strips the provider segment from a provider/model specifier", () => {
258
+ expect(stripProviderPrefix("anthropic/claude-opus-4-6")).toBe("claude-opus-4-6");
259
+ });
260
+
261
+ it("only strips up to the first slash", () => {
262
+ expect(stripProviderPrefix("openrouter/moonshotai/kimi-k2.6")).toBe("moonshotai/kimi-k2.6");
263
+ });
264
+
265
+ it("leaves bare model names and leading-slash specifiers unchanged", () => {
266
+ expect(stripProviderPrefix("claude-opus-4-6")).toBe("claude-opus-4-6");
267
+ expect(stripProviderPrefix("/weird")).toBe("/weird");
268
+ });
269
+ });
270
+
271
+ describe("buildStopHookScript", () => {
272
+ it("reads the gate URL/token from env and emits a block decision", () => {
273
+ const script = buildStopHookScript();
274
+ expect(script.startsWith("#!/usr/bin/env bash")).toBe(true);
275
+ expect(script).toContain("TERRAMEND_GATE_URL");
276
+ expect(script).toContain("TERRAMEND_GATE_TOKEN");
277
+ expect(script).toContain('{decision: "block", reason: $reason}');
278
+ // absent gate URL must disable the hook (non-CI local dev path).
279
+ expect(script).toContain('if [ -z "$url" ]; then exit 0; fi');
280
+ });
281
+ });
282
+
283
+ describe("tailLines", () => {
284
+ it("returns short text unchanged", () => {
285
+ expect(tailLines("hello\nworld", 100)).toBe("hello\nworld");
286
+ });
287
+
288
+ it("drops the partial first line from the capped tail", () => {
289
+ expect(tailLines("aaaa\nbbbb\ncccc", 9)).toBe("cccc");
290
+ });
291
+
292
+ it("returns the raw tail when no newline falls inside the window", () => {
293
+ expect(tailLines("abcdefghij", 5)).toBe("fghij");
294
+ });
295
+
296
+ it("returns the raw tail when the only newline is at the window start", () => {
297
+ expect(tailLines("aaa\nbcdef", 6)).toBe("\nbcdef");
298
+ });
299
+ });
300
+
301
+ // ── runner + agent harness ──────────────────────────────────────────────────────
302
+
303
+ const tempDirs: string[] = [];
304
+
305
+ afterAll(() => {
306
+ for (const dir of tempDirs) {
307
+ rmSync(dir, { recursive: true, force: true });
308
+ }
309
+ });
310
+
311
+ afterEach(() => {
312
+ vi.unstubAllEnvs();
313
+ });
314
+
315
+ /** one NDJSON line. */
316
+ function line(event: unknown): string {
317
+ return `${JSON.stringify(event)}\n`;
318
+ }
319
+
320
+ interface SpawnScript {
321
+ stdout?: string[];
322
+ stderr?: string[];
323
+ exitCode?: number;
324
+ reject?: Error;
325
+ }
326
+
327
+ /** drive the spawn fake: feed scripted stdout/stderr chunks, then exit. */
328
+ function scriptSpawn(script: SpawnScript): void {
329
+ spawnMock.mockImplementation(async (options: SpawnOptions) => {
330
+ for (const chunk of script.stdout ?? []) {
331
+ await Promise.resolve(options.onStdout?.(chunk));
332
+ }
333
+ for (const chunk of script.stderr ?? []) {
334
+ options.onStderr?.(chunk);
335
+ }
336
+ if (script.reject) throw script.reject;
337
+ return { stdout: "", stderr: "", exitCode: script.exitCode ?? 0, durationMs: 5 };
338
+ });
339
+ }
340
+
341
+ function lastSpawnOptions(): SpawnOptions {
342
+ const call = spawnMock.mock.calls.at(-1);
343
+ if (!call) throw new Error("spawn was never called");
344
+ return call[0];
345
+ }
346
+
347
+ function makeTodoTracker(): TodoTracker & {
348
+ update: ReturnType<typeof vi.fn>;
349
+ cancel: ReturnType<typeof vi.fn>;
350
+ flush: ReturnType<typeof vi.fn>;
351
+ } {
352
+ return {
353
+ enabled: true,
354
+ update: vi.fn(),
355
+ cancel: vi.fn(),
356
+ flush: vi.fn(async () => {}),
357
+ } as unknown as TodoTracker & {
358
+ update: ReturnType<typeof vi.fn>;
359
+ cancel: ReturnType<typeof vi.fn>;
360
+ flush: ReturnType<typeof vi.fn>;
361
+ };
362
+ }
363
+
364
+ function runParams(overrides?: {
365
+ todoTracker?: TodoTracker;
366
+ onToolUse?: (event: { toolName: string; input: unknown }) => void;
367
+ }) {
368
+ return {
369
+ label: "Terramend",
370
+ cmd: "/fake/bin/claude.exe",
371
+ args: ["--output-format", "stream-json"],
372
+ cwd: process.cwd(),
373
+ env: {},
374
+ todoTracker: overrides?.todoTracker,
375
+ onToolUse: overrides?.onToolUse,
376
+ };
377
+ }
378
+
379
+ const successResultEvent = {
380
+ type: "result",
381
+ subtype: "success",
382
+ session_id: "ses_1",
383
+ num_turns: 3,
384
+ total_cost_usd: 0.42,
385
+ usage: {
386
+ input_tokens: 10,
387
+ output_tokens: 20,
388
+ cache_read_input_tokens: 30,
389
+ cache_creation_input_tokens: 40,
390
+ },
391
+ result: "final answer",
392
+ };
393
+
394
+ describe("runClaude", () => {
395
+ beforeEach(() => {
396
+ vi.clearAllMocks();
397
+ });
398
+
399
+ it("parses the NDJSON stream into output, usage, and session id", async () => {
400
+ scriptSpawn({
401
+ stdout: [
402
+ line({ type: "system", subtype: "init", session_id: "ses_1", parent_tool_use_id: null }),
403
+ line({
404
+ type: "assistant",
405
+ session_id: "ses_1",
406
+ parent_tool_use_id: null,
407
+ message: { content: [{ type: "text", text: "working on it" }] },
408
+ }),
409
+ line(successResultEvent),
410
+ ],
411
+ });
412
+
413
+ const result = await runClaude(runParams());
414
+
415
+ expect(result.success).toBe(true);
416
+ expect(result.output).toBe("final answer");
417
+ expect(result.sessionId).toBe("ses_1");
418
+ expect(result.usage).toEqual({
419
+ agent: "claude",
420
+ inputTokens: 80, // input + cacheRead + cacheWrite
421
+ outputTokens: 20,
422
+ cacheReadTokens: 30,
423
+ cacheWriteTokens: 40,
424
+ costUsd: 0.42,
425
+ });
426
+ });
427
+
428
+ it("reassembles events split across stdout chunks", async () => {
429
+ const whole = line(successResultEvent);
430
+ scriptSpawn({ stdout: [whole.slice(0, 25), whole.slice(25)] });
431
+
432
+ const result = await runClaude(runParams());
433
+
434
+ expect(result.success).toBe(true);
435
+ expect(result.output).toBe("final answer");
436
+ });
437
+
438
+ it("forwards tool_use to onToolUse and keeps subagent text out of the final output", async () => {
439
+ const onToolUse = vi.fn();
440
+ scriptSpawn({
441
+ stdout: [
442
+ line({
443
+ type: "assistant",
444
+ session_id: "ses_1",
445
+ parent_tool_use_id: null,
446
+ message: { content: [{ type: "text", text: "orchestrator answer" }] },
447
+ }),
448
+ line({
449
+ type: "assistant",
450
+ session_id: "ses_1",
451
+ parent_tool_use_id: null,
452
+ message: {
453
+ content: [
454
+ {
455
+ type: "tool_use",
456
+ id: "tu_1",
457
+ name: "mcp__terramend__shell",
458
+ input: { command: "ls" },
459
+ },
460
+ {
461
+ type: "tool_use",
462
+ id: "tu_task",
463
+ name: "Task",
464
+ input: { description: "security lens", subagent_type: REVIEWER_AGENT_NAME },
465
+ },
466
+ ],
467
+ },
468
+ }),
469
+ // subagent events carry parent_tool_use_id of the dispatching tool_use.
470
+ line({
471
+ type: "assistant",
472
+ session_id: "ses_1",
473
+ parent_tool_use_id: "tu_task",
474
+ message: { content: [{ type: "text", text: "subagent report-back" }] },
475
+ }),
476
+ line({
477
+ type: "user",
478
+ session_id: "ses_1",
479
+ parent_tool_use_id: null,
480
+ message: {
481
+ content: [
482
+ { type: "tool_result", tool_use_id: "tu_1", content: "file-a\nfile-b" },
483
+ {
484
+ type: "tool_result",
485
+ tool_use_id: "tu_task",
486
+ content: [{ type: "text", text: "lens done" }, "raw string", { other: 1 }],
487
+ is_error: true,
488
+ },
489
+ ],
490
+ },
491
+ }),
492
+ ],
493
+ });
494
+
495
+ const result = await runClaude(runParams({ onToolUse }));
496
+
497
+ expect(result.success).toBe(true);
498
+ // subagent text must not clobber the orchestrator's final answer.
499
+ expect(result.output).toBe("orchestrator answer");
500
+ expect(onToolUse).toHaveBeenCalledTimes(2);
501
+ expect(onToolUse).toHaveBeenCalledWith({
502
+ toolName: "mcp__terramend__shell",
503
+ input: { command: "ls" },
504
+ });
505
+ });
506
+
507
+ it("tracks orchestrator TodoWrite, ignores subagent todos, and flushes on success", async () => {
508
+ const todoTracker = makeTodoTracker();
509
+ const todos = { todos: [{ content: "step", status: "pending" }] };
510
+ scriptSpawn({
511
+ stdout: [
512
+ line({
513
+ type: "assistant",
514
+ session_id: "ses_1",
515
+ parent_tool_use_id: null,
516
+ message: {
517
+ content: [
518
+ { type: "tool_use", id: "tu_task", name: "Agent", input: { description: "lens" } },
519
+ { type: "tool_use", id: "tu_2", name: "TodoWrite", input: todos },
520
+ ],
521
+ },
522
+ }),
523
+ line({
524
+ type: "assistant",
525
+ session_id: "ses_1",
526
+ parent_tool_use_id: "tu_task",
527
+ message: {
528
+ content: [{ type: "tool_use", id: "tu_3", name: "TodoWrite", input: todos }],
529
+ },
530
+ }),
531
+ ],
532
+ });
533
+
534
+ const result = await runClaude(runParams({ todoTracker }));
535
+
536
+ expect(result.success).toBe(true);
537
+ expect(todoTracker.update).toHaveBeenCalledTimes(1);
538
+ expect(todoTracker.update).toHaveBeenCalledWith(todos);
539
+ expect(todoTracker.flush).toHaveBeenCalledTimes(1);
540
+ expect(todoTracker.cancel).not.toHaveBeenCalled();
541
+ });
542
+
543
+ it("cancels todo tracking when the agent calls report_progress", async () => {
544
+ const todoTracker = makeTodoTracker();
545
+ scriptSpawn({
546
+ stdout: [
547
+ line({
548
+ type: "assistant",
549
+ session_id: "ses_1",
550
+ parent_tool_use_id: null,
551
+ message: {
552
+ content: [
553
+ {
554
+ type: "tool_use",
555
+ id: "tu_1",
556
+ name: "mcp__terramend__report_progress",
557
+ input: {},
558
+ },
559
+ ],
560
+ },
561
+ }),
562
+ ],
563
+ });
564
+
565
+ await runClaude(runParams({ todoTracker }));
566
+
567
+ expect(todoTracker.cancel).toHaveBeenCalled();
568
+ });
569
+
570
+ it("accumulates per-message usage when no result event arrives", async () => {
571
+ scriptSpawn({
572
+ stdout: [
573
+ line({
574
+ type: "assistant",
575
+ session_id: "ses_1",
576
+ parent_tool_use_id: null,
577
+ message: {
578
+ content: [{ type: "text", text: "partial" }],
579
+ usage: {
580
+ input_tokens: 5,
581
+ output_tokens: 7,
582
+ cache_read_input_tokens: 11,
583
+ cache_creation_input_tokens: 13,
584
+ },
585
+ },
586
+ }),
587
+ ],
588
+ });
589
+
590
+ const result = await runClaude(runParams());
591
+
592
+ expect(result.success).toBe(true);
593
+ expect(result.output).toBe("partial");
594
+ expect(result.usage).toEqual({
595
+ agent: "claude",
596
+ inputTokens: 29,
597
+ outputTokens: 7,
598
+ cacheReadTokens: 11,
599
+ cacheWriteTokens: 13,
600
+ costUsd: undefined,
601
+ });
602
+ });
603
+
604
+ it("classifies a synthetic-stop result (is_error + subtype success) as a failure", async () => {
605
+ scriptSpawn({
606
+ stdout: [
607
+ line({
608
+ type: "result",
609
+ subtype: "success",
610
+ is_error: true,
611
+ api_error_status: 401,
612
+ result: "OAuth token revoked",
613
+ session_id: "ses_1",
614
+ }),
615
+ ],
616
+ });
617
+
618
+ const result = await runClaude(runParams());
619
+
620
+ expect(result.success).toBe(false);
621
+ expect(result.error).toBe("OAuth token revoked");
622
+ expect(result.sessionId).toBe("ses_1");
623
+ });
624
+
625
+ it("synthesizes a message when the synthetic-stop result carries no text", async () => {
626
+ scriptSpawn({
627
+ stdout: [line({ type: "result", subtype: "success", is_error: true, api_error_status: 529 })],
628
+ });
629
+
630
+ const result = await runClaude(runParams());
631
+
632
+ expect(result.success).toBe(false);
633
+ expect(result.error).toContain("api_error_status=529");
634
+ });
635
+
636
+ it("fails on error_max_turns and surfaces the errors payload", async () => {
637
+ const todoTracker = makeTodoTracker();
638
+ scriptSpawn({
639
+ stdout: [
640
+ line({ type: "result", subtype: "error_max_turns", errors: ["hit the turn ceiling"] }),
641
+ ],
642
+ });
643
+
644
+ const result = await runClaude(runParams({ todoTracker }));
645
+
646
+ expect(result.success).toBe(false);
647
+ expect(result.error).toBe("hit the turn ceiling");
648
+ // exit code was 0, so the tracker still flushes (result gating happens upstream)
649
+ expect(todoTracker.flush).toHaveBeenCalled();
650
+ });
651
+
652
+ it("fails on error_during_execution and falls back to the subtype when errors are empty", async () => {
653
+ scriptSpawn({
654
+ stdout: [line({ type: "result", subtype: "error_during_execution" })],
655
+ });
656
+
657
+ const result = await runClaude(runParams());
658
+
659
+ expect(result.success).toBe(false);
660
+ expect(result.error).toBe("result subtype: error_during_execution");
661
+ });
662
+
663
+ it("fails on any other error_* subtype", async () => {
664
+ scriptSpawn({
665
+ stdout: [line({ type: "result", subtype: "error_quota", errors: ["quota exceeded"] })],
666
+ });
667
+
668
+ const result = await runClaude(runParams());
669
+
670
+ expect(result.success).toBe(false);
671
+ expect(result.error).toBe("quota exceeded");
672
+ });
673
+
674
+ it("survives unknown event types and handler throws", async () => {
675
+ scriptSpawn({
676
+ stdout: [
677
+ line({ type: "banana", session_id: "ses_1" }),
678
+ // message.content is a number — the assistant handler throws, gets caught.
679
+ line({ type: "assistant", session_id: "ses_1", message: { content: 42 } }),
680
+ // unknown non-error result subtype is logged but not fatal.
681
+ line({ type: "result", subtype: "checkpoint" }),
682
+ line(successResultEvent),
683
+ ],
684
+ });
685
+
686
+ const result = await runClaude(runParams());
687
+
688
+ expect(result.success).toBe(true);
689
+ expect(result.output).toBe("final answer");
690
+ });
691
+
692
+ it("prefers stderr over the NDJSON tail on a non-zero exit", async () => {
693
+ const todoTracker = makeTodoTracker();
694
+ scriptSpawn({
695
+ stdout: [line({ type: "system", subtype: "init", session_id: "ses_1" })],
696
+ stderr: ['API error: {"status": 429, "message": "rate limited"}'],
697
+ exitCode: 1,
698
+ });
699
+
700
+ const result = await runClaude(runParams({ todoTracker }));
701
+
702
+ expect(result.success).toBe(false);
703
+ expect(result.error).toBe('API error: {"status": 429, "message": "rate limited"}');
704
+ expect(todoTracker.cancel).toHaveBeenCalled();
705
+ expect(todoTracker.flush).not.toHaveBeenCalled();
706
+ });
707
+
708
+ it("prefers human-readable non-JSON stdout chrome over the NDJSON tail on exit 1", async () => {
709
+ scriptSpawn({
710
+ stdout: [
711
+ line({ type: "system", subtype: "init", session_id: "ses_1" }),
712
+ "You have exceeded your usage quota for today\n",
713
+ ],
714
+ exitCode: 1,
715
+ });
716
+
717
+ const result = await runClaude(runParams());
718
+
719
+ expect(result.success).toBe(false);
720
+ expect(result.error).toBe("You have exceeded your usage quota for today");
721
+ });
722
+
723
+ it("prefers the structured result error over everything else on exit 1", async () => {
724
+ scriptSpawn({
725
+ stdout: [
726
+ line({
727
+ type: "result",
728
+ subtype: "success",
729
+ is_error: true,
730
+ api_error_status: 401,
731
+ result: "invalid x-api-key",
732
+ }),
733
+ ],
734
+ stderr: ["noisy stderr"],
735
+ exitCode: 1,
736
+ });
737
+
738
+ const result = await runClaude(runParams());
739
+
740
+ expect(result.success).toBe(false);
741
+ expect(result.error).toBe("invalid x-api-key");
742
+ });
743
+
744
+ it("reports an unknown error when a non-zero exit produced no output at all", async () => {
745
+ scriptSpawn({ exitCode: 7 });
746
+
747
+ const result = await runClaude(runParams());
748
+
749
+ expect(result.success).toBe(false);
750
+ expect(result.error).toContain("unknown error - no output from Claude CLI");
751
+ });
752
+
753
+ it("classifies a zero-event run with a provider stderr error", async () => {
754
+ scriptSpawn({
755
+ stderr: ["Your credit balance is too low to access the Anthropic API"],
756
+ exitCode: 0,
757
+ });
758
+
759
+ const result = await runClaude(runParams());
760
+
761
+ expect(result.success).toBe(false);
762
+ expect(result.error).toBe("provider error: provider billing exhausted");
763
+ });
764
+
765
+ it("converts a SpawnTimeoutError into a diagnosed failure result", async () => {
766
+ const todoTracker = makeTodoTracker();
767
+ scriptSpawn({
768
+ stdout: [
769
+ line({
770
+ type: "assistant",
771
+ session_id: "ses_1",
772
+ message: { content: [{ type: "text", text: "got this far" }] },
773
+ }),
774
+ ],
775
+ reject: new SpawnTimeoutError(
776
+ "activity timeout: no output for 900s",
777
+ SPAWN_ACTIVITY_TIMEOUT_CODE,
778
+ ),
779
+ });
780
+
781
+ const result = await runClaude(runParams({ todoTracker }));
782
+
783
+ expect(result.success).toBe(false);
784
+ expect(result.error).toContain("activity timeout: no output for 900s");
785
+ expect(result.error).toContain("1 events were processed before the hang");
786
+ expect(result.output).toBe("got this far");
787
+ expect(todoTracker.cancel).toHaveBeenCalled();
788
+ });
789
+
790
+ it("diagnoses a zero-event hang as a possible API-reachability problem", async () => {
791
+ scriptSpawn({
792
+ reject: new SpawnTimeoutError(
793
+ "activity timeout: no output for 900s",
794
+ SPAWN_ACTIVITY_TIMEOUT_CODE,
795
+ ),
796
+ });
797
+
798
+ const result = await runClaude(runParams());
799
+
800
+ expect(result.success).toBe(false);
801
+ expect(result.error).toContain("0 stdout events");
802
+ });
803
+ });
804
+
805
+ describe("claude.run", () => {
806
+ let gateDispose: ReturnType<typeof vi.fn<() => Promise<void>>>;
807
+
808
+ function makeRunCtx(overrides?: Partial<AgentRunContext>): AgentRunContext {
809
+ const dir = mkdtempSync(join(tmpdir(), "terramend-claude-run-"));
810
+ tempDirs.push(dir);
811
+ const ctx = {
812
+ payload: {},
813
+ resolvedModel: "anthropic/claude-opus-4-6",
814
+ mcpServerUrl: "http://127.0.0.1:7777/mcp",
815
+ mcpServerToken: "test-mcp-token",
816
+ tmpdir: dir,
817
+ instructions: {
818
+ full: "fix the bug",
819
+ system: "",
820
+ user: "",
821
+ eventInstructions: "",
822
+ event: "",
823
+ runtime: "",
824
+ },
825
+ toolState: {} as unknown as ToolState,
826
+ apiToken: "",
827
+ ...overrides,
828
+ };
829
+ return ctx as unknown as AgentRunContext;
830
+ }
831
+
832
+ beforeEach(() => {
833
+ vi.clearAllMocks();
834
+ // installManagedSettings shells out to sudo when CI === "true" — keep it off.
835
+ vi.stubEnv("CI", "false");
836
+ vi.stubEnv("ANTHROPIC_API_KEY", undefined);
837
+ vi.stubEnv("CLAUDE_CODE_OAUTH_TOKEN", undefined);
838
+ vi.stubEnv("BEDROCK_MODEL_ID", undefined);
839
+ vi.stubEnv("VERTEX_MODEL_ID", undefined);
840
+ gateDispose = vi.fn(async () => {});
841
+ startGateServerMock.mockResolvedValue({
842
+ url: "http://127.0.0.1:9999/gates",
843
+ token: "gate-token",
844
+ [Symbol.asyncDispose]: gateDispose,
845
+ });
846
+ scriptSpawn({ stdout: [line(successResultEvent)] });
847
+ });
848
+
849
+ it("assembles the CLI invocation: args, MCP config, gate env, and result passthrough", async () => {
850
+ const ctx = makeRunCtx();
851
+ const result = await claude.run(ctx);
852
+
853
+ expect(result.success).toBe(true);
854
+ expect(result.output).toBe("final answer");
855
+ expect(installMock).toHaveBeenCalled();
856
+ expect(installBundledSkillsMock).toHaveBeenCalledWith({ home: ctx.tmpdir });
857
+
858
+ const opts = lastSpawnOptions();
859
+ expect(opts.cmd).toBe("/fake/bin/claude.exe");
860
+ expect(opts.cwd).toBe(process.cwd());
861
+
862
+ const args = opts.args;
863
+ expect(args.slice(0, 2)).toEqual(["--output-format", "stream-json"]);
864
+ expect(args).toContain("--dangerously-skip-permissions");
865
+ expect(args.at(-2)).toBe("-p");
866
+ expect(args.at(-1)).toBe("fix the bug");
867
+
868
+ // model is stripped of the provider prefix
869
+ expect(args[args.indexOf("--model") + 1]).toBe("claude-opus-4-6");
870
+
871
+ // disallowed exec tools ride --disallowedTools
872
+ const disallowed = args[args.indexOf("--disallowedTools") + 1] ?? "";
873
+ expect(disallowed.split(",").sort()).toEqual([...CLAUDE_EXEC_TOOL_DENY_RULES].sort());
874
+
875
+ // --agents carries the reviewer definition
876
+ const agentsJson = JSON.parse(args[args.indexOf("--agents") + 1] ?? "{}") as Record<
877
+ string,
878
+ unknown
879
+ >;
880
+ expect(agentsJson[REVIEWER_AGENT_NAME]).toBeDefined();
881
+
882
+ // MCP config written into the per-run tmpdir
883
+ const mcpConfigPath = args[args.indexOf("--mcp-config") + 1] ?? "";
884
+ expect(mcpConfigPath).toBe(join(ctx.tmpdir, ".claude", "mcp.json"));
885
+ const mcpConfig = JSON.parse(readFileSync(mcpConfigPath, "utf-8")) as {
886
+ mcpServers: Record<string, { type: string; url: string; headers?: Record<string, string> }>;
887
+ };
888
+ expect(mcpConfig.mcpServers.terramend).toEqual({
889
+ type: "http",
890
+ url: "http://127.0.0.1:7777/mcp",
891
+ headers: { Authorization: "Bearer ${TERRAMEND_MCP_TOKEN}" },
892
+ });
893
+
894
+ // flag settings + pretool gate script + stop hook all land in tmpdir
895
+ const settingsPath = args[args.indexOf("--settings") + 1] ?? "";
896
+ expect(settingsPath).toBe(join(ctx.tmpdir, "terramend-claude-settings.json"));
897
+ expect(existsSync(settingsPath)).toBe(true);
898
+ expect(existsSync(join(ctx.tmpdir, "terramend-stop-hook.sh"))).toBe(true);
899
+
900
+ // gate server env wiring + HOME redirect
901
+ const env = opts.env ?? {};
902
+ expect(env.TERRAMEND_GATE_URL).toBe("http://127.0.0.1:9999/gates");
903
+ expect(env.TERRAMEND_GATE_TOKEN).toBe("gate-token");
904
+ // the MCP bearer token is delivered via env so Claude Code can expand
905
+ // ${TERRAMEND_MCP_TOKEN} in the mcp.json Authorization header.
906
+ expect(env.TERRAMEND_MCP_TOKEN).toBe("test-mcp-token");
907
+ expect(env.HOME).toBe(ctx.tmpdir);
908
+ expect(env.PWD).toBe(process.cwd());
909
+ expect(env.CLAUDE_CODE_USE_BEDROCK).toBeUndefined();
910
+
911
+ expect(gateDispose).toHaveBeenCalledTimes(1);
912
+ expect(finalizeMock).toHaveBeenCalledTimes(1);
913
+ });
914
+
915
+ it("omits --model when no model resolved", async () => {
916
+ await claude.run(makeRunCtx({ resolvedModel: undefined }));
917
+ expect(lastSpawnOptions().args).not.toContain("--model");
918
+ });
919
+
920
+ it("passes the bare Bedrock id through and sets CLAUDE_CODE_USE_BEDROCK", async () => {
921
+ vi.stubEnv("BEDROCK_MODEL_ID", "eu.anthropic.claude-opus-4-7");
922
+ await claude.run(makeRunCtx({ resolvedModel: "eu.anthropic.claude-opus-4-7" }));
923
+
924
+ const opts = lastSpawnOptions();
925
+ expect(opts.args[opts.args.indexOf("--model") + 1]).toBe("eu.anthropic.claude-opus-4-7");
926
+ expect(opts.env?.CLAUDE_CODE_USE_BEDROCK).toBe("1");
927
+ });
928
+
929
+ it("routes Vertex models through env instead of --model", async () => {
930
+ vi.stubEnv("VERTEX_MODEL_ID", "claude-opus-4-1@20250805");
931
+ await claude.run(makeRunCtx({ resolvedModel: "claude-opus-4-1@20250805" }));
932
+
933
+ const opts = lastSpawnOptions();
934
+ expect(opts.args).not.toContain("--model");
935
+ expect(opts.env?.ANTHROPIC_MODEL).toBe("claude-opus-4-1@20250805");
936
+ expect(opts.env?.CLAUDE_CODE_USE_VERTEX).toBe("1");
937
+ });
938
+
939
+ it("strips ANTHROPIC_API_KEY when the OAuth subscription preflight passes", async () => {
940
+ vi.stubEnv("ANTHROPIC_API_KEY", "api-key");
941
+ vi.stubEnv("CLAUDE_CODE_OAUTH_TOKEN", "oauth-token");
942
+ preflightMock.mockResolvedValue({ usable: true });
943
+
944
+ await claude.run(makeRunCtx());
945
+
946
+ expect(preflightMock).toHaveBeenCalledWith({
947
+ token: "oauth-token",
948
+ model: "claude-opus-4-6",
949
+ });
950
+ const env = lastSpawnOptions().env ?? {};
951
+ expect("ANTHROPIC_API_KEY" in env).toBe(false);
952
+ expect(env.CLAUDE_CODE_OAUTH_TOKEN).toBe("oauth-token");
953
+ });
954
+
955
+ it("drops the OAuth token and keeps the API key when the preflight fails", async () => {
956
+ vi.stubEnv("ANTHROPIC_API_KEY", "api-key");
957
+ vi.stubEnv("CLAUDE_CODE_OAUTH_TOKEN", "oauth-token");
958
+ preflightMock.mockResolvedValue({ usable: false, reason: "weekly limit reached" });
959
+
960
+ await claude.run(makeRunCtx());
961
+
962
+ const env = lastSpawnOptions().env ?? {};
963
+ expect("CLAUDE_CODE_OAUTH_TOKEN" in env).toBe(false);
964
+ expect(env.ANTHROPIC_API_KEY).toBe("api-key");
965
+ });
966
+
967
+ it("skips the preflight when no API key competes with the OAuth token", async () => {
968
+ vi.stubEnv("CLAUDE_CODE_OAUTH_TOKEN", "oauth-token");
969
+
970
+ await claude.run(makeRunCtx());
971
+
972
+ expect(preflightMock).not.toHaveBeenCalled();
973
+ expect(lastSpawnOptions().env?.CLAUDE_CODE_OAUTH_TOKEN).toBe("oauth-token");
974
+ });
975
+
976
+ it("installs managed settings via sudo when running in CI", async () => {
977
+ vi.stubEnv("CI", "true");
978
+
979
+ await claude.run(makeRunCtx());
980
+
981
+ expect(execFileSyncMock).toHaveBeenCalledWith("sudo", ["mkdir", "-p", "/etc/claude-code"]);
982
+ const teeCall = execFileSyncMock.mock.calls.find((call) => call[1]?.[0] === "tee");
983
+ expect(teeCall?.[1]).toEqual(["tee", "/etc/claude-code/managed-settings.json"]);
984
+ const teeOptions = teeCall?.[2] as { input: string };
985
+ const managed = JSON.parse(teeOptions.input) as { permissions: { deny: string[] } };
986
+ expect(managed.permissions.deny).toContain("Bash");
987
+ });
988
+
989
+ it("degrades to a warning when the managed-settings install fails", async () => {
990
+ vi.stubEnv("CI", "true");
991
+ execFileSyncMock.mockImplementation(() => {
992
+ throw new Error("sudo: command not found");
993
+ });
994
+
995
+ const result = await claude.run(makeRunCtx());
996
+ expect(result.success).toBe(true);
997
+ });
998
+
999
+ it("skips the managed-settings install outside CI", async () => {
1000
+ await claude.run(makeRunCtx());
1001
+ expect(execFileSyncMock).not.toHaveBeenCalled();
1002
+ });
1003
+
1004
+ it("never strips credentials on the Bedrock route", async () => {
1005
+ vi.stubEnv("ANTHROPIC_API_KEY", "api-key");
1006
+ vi.stubEnv("CLAUDE_CODE_OAUTH_TOKEN", "oauth-token");
1007
+ vi.stubEnv("BEDROCK_MODEL_ID", "eu.anthropic.claude-opus-4-7");
1008
+
1009
+ await claude.run(makeRunCtx({ resolvedModel: "eu.anthropic.claude-opus-4-7" }));
1010
+
1011
+ expect(preflightMock).not.toHaveBeenCalled();
1012
+ const env = lastSpawnOptions().env ?? {};
1013
+ expect(env.ANTHROPIC_API_KEY).toBe("api-key");
1014
+ expect(env.CLAUDE_CODE_OAUTH_TOKEN).toBe("oauth-token");
1015
+ });
1016
+ });