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,190 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { mkdirSync, writeFileSync } from "node:fs";
3
+ import { homedir, userInfo } from "node:os";
4
+ import { join } from "node:path";
5
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
6
+ import { log } from "#app/utils/cli";
7
+ import { installCodexAuth, TERRAMEND_DATA_DIR } from "#app/utils/codexHome";
8
+
9
+ vi.mock("node:fs", async (importOriginal) => {
10
+ const actual = await importOriginal<typeof import("node:fs")>();
11
+ return { ...actual, mkdirSync: vi.fn(), writeFileSync: vi.fn() };
12
+ });
13
+
14
+ vi.mock("node:child_process", async (importOriginal) => {
15
+ const actual = await importOriginal<typeof import("node:child_process")>();
16
+ return { ...actual, execFileSync: vi.fn() };
17
+ });
18
+
19
+ const SAVED_XDG_DATA_HOME = process.env.XDG_DATA_HOME;
20
+
21
+ function makeJwt(payload: Record<string, unknown>): string {
22
+ const segment = Buffer.from(JSON.stringify(payload)).toString("base64url");
23
+ return `header.${segment}.signature`;
24
+ }
25
+
26
+ function codexAuthJson(tokens: Record<string, unknown>): string {
27
+ return JSON.stringify({ auth_mode: "chatgpt", tokens });
28
+ }
29
+
30
+ function writtenAuthFile(): { path: unknown; content: Record<string, unknown>; mode: unknown } {
31
+ const call = vi.mocked(writeFileSync).mock.calls[0];
32
+ if (!call) throw new Error("expected writeFileSync to have been called");
33
+ return {
34
+ path: call[0],
35
+ content: JSON.parse(String(call[1])) as Record<string, unknown>,
36
+ mode: call[2],
37
+ };
38
+ }
39
+
40
+ beforeEach(() => {
41
+ vi.stubEnv("CI", "false");
42
+ });
43
+
44
+ afterEach(() => {
45
+ vi.unstubAllEnvs();
46
+ vi.clearAllMocks();
47
+ vi.restoreAllMocks();
48
+ if (SAVED_XDG_DATA_HOME === undefined) delete process.env.XDG_DATA_HOME;
49
+ else process.env.XDG_DATA_HOME = SAVED_XDG_DATA_HOME;
50
+ });
51
+
52
+ describe("installCodexAuth", () => {
53
+ it("returns null without touching disk when CODEX_AUTH_JSON is absent", () => {
54
+ vi.stubEnv("CODEX_AUTH_JSON", undefined);
55
+
56
+ expect(installCodexAuth()).toBeNull();
57
+ expect(vi.mocked(writeFileSync)).not.toHaveBeenCalled();
58
+ expect(vi.mocked(mkdirSync)).not.toHaveBeenCalled();
59
+ });
60
+
61
+ it("returns null and warns when CODEX_AUTH_JSON is not valid JSON", () => {
62
+ const warning = vi.spyOn(log, "warning").mockImplementation(() => {});
63
+ vi.stubEnv("CODEX_AUTH_JSON", "{not json");
64
+
65
+ expect(installCodexAuth()).toBeNull();
66
+ expect(warning).toHaveBeenCalledWith(expect.stringContaining("CODEX_AUTH_JSON"));
67
+ expect(vi.mocked(writeFileSync)).not.toHaveBeenCalled();
68
+ });
69
+
70
+ it("returns null and warns on a wrong auth_mode shape", () => {
71
+ const warning = vi.spyOn(log, "warning").mockImplementation(() => {});
72
+ vi.stubEnv(
73
+ "CODEX_AUTH_JSON",
74
+ JSON.stringify({ auth_mode: "apikey", tokens: { access_token: "a", refresh_token: "r" } }),
75
+ );
76
+
77
+ expect(installCodexAuth()).toBeNull();
78
+ expect(warning).toHaveBeenCalledOnce();
79
+ });
80
+
81
+ it("materializes auth.json under $HOME locally and exports XDG_DATA_HOME", () => {
82
+ const exp = 1_893_456_000; // 2030-01-01T00:00:00Z
83
+ const accessToken = makeJwt({ exp });
84
+ vi.stubEnv(
85
+ "CODEX_AUTH_JSON",
86
+ codexAuthJson({ access_token: accessToken, refresh_token: "r-1", account_id: "acct-9" }),
87
+ );
88
+
89
+ const result = installCodexAuth();
90
+
91
+ const xdgDataHome = join(homedir(), ".local", "share");
92
+ const authPath = join(xdgDataHome, "opencode", "auth.json");
93
+ expect(result).toEqual({ authPath, xdgDataHome, originalRefresh: "r-1" });
94
+ // load-bearing: every opencode subprocess discovers the auth.json via env
95
+ expect(process.env.XDG_DATA_HOME).toBe(xdgDataHome);
96
+ expect(vi.mocked(mkdirSync)).toHaveBeenCalledWith(join(xdgDataHome, "opencode"), {
97
+ recursive: true,
98
+ });
99
+
100
+ const written = writtenAuthFile();
101
+ expect(written.path).toBe(authPath);
102
+ expect(written.mode).toEqual({ mode: 0o600 });
103
+ expect(written.content).toEqual({
104
+ openai: {
105
+ type: "oauth",
106
+ refresh: "r-1",
107
+ access: accessToken,
108
+ expires: exp * 1000,
109
+ accountId: "acct-9",
110
+ },
111
+ });
112
+ });
113
+
114
+ it("falls back to expires 0 when the access token is not a decodable JWT", () => {
115
+ vi.stubEnv(
116
+ "CODEX_AUTH_JSON",
117
+ codexAuthJson({ access_token: "opaque-token", refresh_token: "r-2" }),
118
+ );
119
+
120
+ const result = installCodexAuth();
121
+
122
+ expect(result).not.toBeNull();
123
+ const written = writtenAuthFile();
124
+ expect(written.content).toEqual({
125
+ openai: { type: "oauth", refresh: "r-2", access: "opaque-token", expires: 0 },
126
+ });
127
+ });
128
+
129
+ it("uses the sudo-bootstrapped terramend data dir in CI", () => {
130
+ vi.stubEnv("CI", "true");
131
+ vi.stubEnv("CODEX_AUTH_JSON", codexAuthJson({ access_token: "a", refresh_token: "r-3" }));
132
+ vi.mocked(execFileSync).mockImplementation(((cmd: string) =>
133
+ cmd === "id" ? "runners\n" : "") as typeof execFileSync);
134
+
135
+ const result = installCodexAuth();
136
+
137
+ expect(result).toEqual({
138
+ authPath: join(TERRAMEND_DATA_DIR, "opencode", "auth.json"),
139
+ xdgDataHome: TERRAMEND_DATA_DIR,
140
+ originalRefresh: "r-3",
141
+ });
142
+ expect(process.env.XDG_DATA_HOME).toBe(TERRAMEND_DATA_DIR);
143
+ const user = userInfo().username;
144
+ expect(vi.mocked(execFileSync)).toHaveBeenCalledWith(
145
+ "sudo",
146
+ ["-n", "chown", `${user}:runners`, TERRAMEND_DATA_DIR],
147
+ { stdio: "pipe" },
148
+ );
149
+ });
150
+
151
+ it("falls back to the username as group when `id -gn` fails", () => {
152
+ vi.stubEnv("CI", "true");
153
+ vi.stubEnv("CODEX_AUTH_JSON", codexAuthJson({ access_token: "a", refresh_token: "r-4" }));
154
+ vi.mocked(execFileSync).mockImplementation(((cmd: string) => {
155
+ if (cmd === "id") throw new Error("id: command not found");
156
+ return "";
157
+ }) as typeof execFileSync);
158
+
159
+ expect(installCodexAuth()).not.toBeNull();
160
+
161
+ const user = userInfo().username;
162
+ expect(vi.mocked(execFileSync)).toHaveBeenCalledWith(
163
+ "sudo",
164
+ ["-n", "chown", `${user}:${user}`, TERRAMEND_DATA_DIR],
165
+ { stdio: "pipe" },
166
+ );
167
+ });
168
+
169
+ it("fails closed in CI when the sudo bootstrap is unavailable", () => {
170
+ vi.stubEnv("CI", "true");
171
+ vi.stubEnv("CODEX_AUTH_JSON", codexAuthJson({ access_token: "a", refresh_token: "r-5" }));
172
+ vi.mocked(execFileSync).mockImplementation((() => {
173
+ throw new Error("sudo: a password is required");
174
+ }) as typeof execFileSync);
175
+
176
+ expect(() => installCodexAuth()).toThrow(/failed to bootstrap .*terramend/);
177
+ expect(vi.mocked(writeFileSync)).not.toHaveBeenCalled();
178
+ });
179
+
180
+ it("stringifies non-Error bootstrap failures into the fail-closed message", () => {
181
+ vi.stubEnv("CI", "true");
182
+ vi.stubEnv("CODEX_AUTH_JSON", codexAuthJson({ access_token: "a", refresh_token: "r-6" }));
183
+ vi.mocked(execFileSync).mockImplementation((() => {
184
+ // eslint-style non-Error throw — exercises the String(err) fallback
185
+ throw "sudo denied";
186
+ }) as typeof execFileSync);
187
+
188
+ expect(() => installCodexAuth()).toThrow(/sudo denied/);
189
+ });
190
+ });
@@ -0,0 +1,191 @@
1
+ // Codex-to-OpenCode auth bridging for the action runtime.
2
+ //
3
+ // `CODEX_AUTH_JSON` (a Codex CLI `auth.json` blob) is read from the environment
4
+ // — supplied directly as a GitHub Actions secret or workflow `env:` value. The
5
+ // hosted per-org secret store + `dbSecrets` delivery + server-side rotation
6
+ // (`maybeRotateCodexSecret`) were removed with the rest of the managed backend,
7
+ // so the standalone fork reads the env value verbatim.
8
+ //
9
+ // Caveat (GH Actions secrets are immutable at runtime): the OAuth refresh chain
10
+ // rotates on use, so a token stashed as a static secret expires on its first
11
+ // refresh (~1h). Codex auth is therefore best for short runs; longer runs rely
12
+ // on OpenCode's in-process CodexAuthPlugin to refresh mid-run. `entryPost.ts`
13
+ // still detects a mid-run rotation, but its hosted write-back is now a no-op
14
+ // without a backend to persist to. See wiki/codex-auth.md.
15
+ //
16
+ // This utility then:
17
+ // 1. parses + validates the env value
18
+ // 2. decodes the access_token JWT's `exp` claim so opencode knows how
19
+ // long to trust the token before its CodexAuthPlugin attempts its
20
+ // own mid-run refresh
21
+ // 3. converts Codex's shape `{ auth_mode, tokens: { access_token,
22
+ // refresh_token, id_token?, account_id? } }` into OpenCode's shape
23
+ // `{ openai: { type: "oauth", refresh, access, expires, accountId } }`
24
+ // 4. materializes it to disk under a path the MCP-shell mount-namespace
25
+ // sandbox can hide from bash: `/var/lib/terramend/opencode/auth.json` in
26
+ // CI (sudo-bootstrapped, fail-closed if sudo unavailable),
27
+ // `$HOME/.local/share/opencode/auth.json` locally (sandbox is no-op
28
+ // locally so the path is irrelevant to security)
29
+ // 5. returns the path + the original refresh token so the post-run hook
30
+ // can detect a mid-run rotation and write back to Terramend
31
+ //
32
+ // Why `/var/lib/terramend/` and not `$HOME` in CI: bash via MCP runs inside a
33
+ // mount namespace that overlays tmpfs on `/var/lib/terramend/` (see FS_MOUNTS
34
+ // in action/mcp/shell.ts), so bash sees an empty dir while opencode's
35
+ // internal auth module — which runs in the agent process outside that
36
+ // namespace — reads/writes the real file. `$HOME` can't be tmpfs-overlaid
37
+ // without breaking the agent's legitimate need to access ~/.npm, ~/.cache,
38
+ // etc.
39
+ //
40
+ // See [wiki/codex-auth.md] for the full data-flow picture.
41
+
42
+ import { execFileSync } from "node:child_process";
43
+ import { mkdirSync, writeFileSync } from "node:fs";
44
+ import { homedir, userInfo } from "node:os";
45
+ import { join } from "node:path";
46
+ import { log } from "#app/utils/cli";
47
+ import { decodeJwtExpMs, parseCodexAuthBody } from "#app/utils/codexOAuth";
48
+
49
+ const CODEX_AUTH_ENV = "CODEX_AUTH_JSON";
50
+
51
+ /** sandbox-hidden home for terramend-managed on-disk secrets in CI. bash via
52
+ * MCP shell tmpfs-overlays this path; opencode's internal auth module
53
+ * bypasses external_directory and reaches the real file. mirrors the
54
+ * pattern in action/agents/claude.ts installManagedSettings.
55
+ *
56
+ * not used for codex auth in local dev — the sandbox is no-op there, so
57
+ * the path doesn't matter. local dev keeps the existing $HOME path. */
58
+ export const TERRAMEND_DATA_DIR = "/var/lib/terramend";
59
+
60
+ interface OpenCodeAuthFile {
61
+ openai: {
62
+ type: "oauth";
63
+ refresh: string;
64
+ access: string;
65
+ expires: number;
66
+ accountId?: string;
67
+ };
68
+ }
69
+
70
+ export interface InstalledCodexAuth {
71
+ /** absolute path of the auth.json we wrote — caller passes this to the
72
+ * post-hook via core.saveState for refresh-detection later. */
73
+ authPath: string;
74
+ /** value to set as XDG_DATA_HOME for the OpenCode subprocess. */
75
+ xdgDataHome: string;
76
+ /** refresh_token from the env at materialization time. post-hook
77
+ * compares against the on-disk file after the run to detect whether
78
+ * OpenCode refreshed during the session (only happens on long runs
79
+ * that span >50min — see wiki/codex-auth.md "Concurrency"). */
80
+ originalRefresh: string;
81
+ }
82
+
83
+ /** materialize CODEX_AUTH_JSON from env into a disk path OpenCode reads from.
84
+ * returns null when the env var is absent, malformed, or wrong auth mode —
85
+ * caller treats null as "no codex auth, fall through to API key flow".
86
+ *
87
+ * The env value is read as-is — we parse + write it here and set
88
+ * `process.env.XDG_DATA_HOME` so every opencode subprocess discovers the
89
+ * auth.json; no refresh and no DB interaction. Freshness is the supplier's
90
+ * responsibility (see the header caveat on static-secret expiry). */
91
+ export function installCodexAuth(): InstalledCodexAuth | null {
92
+ const raw = process.env[CODEX_AUTH_ENV];
93
+ if (!raw) return null;
94
+
95
+ const body = parseCodexAuthBody(raw);
96
+ if (!body) {
97
+ log.warning(`» ${CODEX_AUTH_ENV} present but malformed; ignoring`);
98
+ return null;
99
+ }
100
+
101
+ // decode the access_token's JWT exp so opencode trusts the token until
102
+ // its real expiry (no need to refresh on first request). null exp ->
103
+ // fall back to "expires: 0" so opencode refreshes immediately on first
104
+ // request (the old behavior).
105
+ const expiresMs = decodeJwtExpMs(body.tokens.access_token) ?? 0;
106
+
107
+ const xdgDataHome = resolveDataHome();
108
+ const opencodeDir = join(xdgDataHome, "opencode");
109
+ const authPath = join(opencodeDir, "auth.json");
110
+
111
+ const opencodeAuth: OpenCodeAuthFile = {
112
+ openai: {
113
+ type: "oauth",
114
+ refresh: body.tokens.refresh_token,
115
+ access: body.tokens.access_token,
116
+ expires: expiresMs,
117
+ ...(body.tokens.account_id ? { accountId: body.tokens.account_id } : {}),
118
+ },
119
+ };
120
+
121
+ mkdirSync(opencodeDir, { recursive: true });
122
+ writeFileSync(authPath, `${JSON.stringify(opencodeAuth, null, 2)}\n`, { mode: 0o600 });
123
+
124
+ // point every opencode subprocess in this run (agent spawn + `opencode
125
+ // models` introspection) at this auth.json. only opencode reads
126
+ // XDG_DATA_HOME and this only fires on codex runs, so the blast radius is
127
+ // exactly the subprocesses that must discover the OAuth-routed openai/* models.
128
+ process.env.XDG_DATA_HOME = xdgDataHome;
129
+
130
+ log.info(`» installed Codex auth at ${authPath}`);
131
+
132
+ return {
133
+ authPath,
134
+ xdgDataHome,
135
+ originalRefresh: body.tokens.refresh_token,
136
+ };
137
+ }
138
+
139
+ /** pick the XDG_DATA_HOME for codex auth.
140
+ *
141
+ * - **local dev (CI != true)**: use $HOME. mount-namespace sandbox is no-op
142
+ * locally so the file isn't protected from bash either way; codex auth on
143
+ * a developer's machine is the developer's responsibility.
144
+ * - **CI**: bootstrap /var/lib/terramend via sudo. MCP shell's mount namespace
145
+ * tmpfs-overlays this path, and claude managed-settings + opencode
146
+ * external_directory both deny it — three independent layers.
147
+ *
148
+ * **fail closed in CI** when the sudo bootstrap fails. falling back to
149
+ * $HOME silently strips two of the three protection layers — the wiki
150
+ * claims three layers; degrading to one without a hard error contradicts
151
+ * that claim and is exactly the kind of silent security regression the
152
+ * reviewer should never have to catch. operators on locked-down runners
153
+ * that can't passwordless-sudo should re-provision sudo or remove
154
+ * `CODEX_AUTH_JSON` from the run entirely. */
155
+ function resolveDataHome(): string {
156
+ if (process.env.CI !== "true") return join(homedir(), ".local", "share");
157
+ bootstrapTerramendDataDir();
158
+ return TERRAMEND_DATA_DIR;
159
+ }
160
+
161
+ function bootstrapTerramendDataDir(): void {
162
+ const user = userInfo().username;
163
+ // `id -gn $user` resolves the user's primary group name correctly even on
164
+ // self-hosted images where the group isn't `<user>:<user>` (e.g., `runner`
165
+ // belongs to `runner`, but a self-hosted setup might use `users`, `docker`,
166
+ // or a project-specific gid). avoids the brittle "group has same name as
167
+ // user" assumption.
168
+ let primaryGroup: string;
169
+ try {
170
+ primaryGroup = execFileSync("id", ["-gn", user], { stdio: "pipe", encoding: "utf-8" }).trim();
171
+ } catch {
172
+ primaryGroup = user;
173
+ }
174
+ // `-n` (non-interactive) makes sudo fail-fast on locked-down runners
175
+ // instead of prompting and timing out.
176
+ try {
177
+ execFileSync("sudo", ["-n", "mkdir", "-p", TERRAMEND_DATA_DIR], { stdio: "pipe" });
178
+ execFileSync("sudo", ["-n", "chown", `${user}:${primaryGroup}`, TERRAMEND_DATA_DIR], {
179
+ stdio: "pipe",
180
+ });
181
+ execFileSync("sudo", ["-n", "chmod", "700", TERRAMEND_DATA_DIR], { stdio: "pipe" });
182
+ } catch (err) {
183
+ throw new Error(
184
+ `failed to bootstrap ${TERRAMEND_DATA_DIR} (required for codex auth in CI): ${err instanceof Error ? err.message : String(err)}. ` +
185
+ `the MCP shell's mount-namespace sandbox cannot protect the auth file when it lives under $HOME, ` +
186
+ `and silently falling back would contradict the "three independent layers" claim in wiki/codex-auth.md. ` +
187
+ `passwordless sudo is required for codex auth on this runner — either configure it, or remove ` +
188
+ `CODEX_AUTH_JSON from the run.`,
189
+ );
190
+ }
191
+ }
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Pure-stdlib (fetch + Buffer) Codex OAuth refresh + JWT exp decoding.
3
+ *
4
+ * Lives here (not in codexAuth.ts) so the Next.js server side can import it
5
+ * via terramend/internal without dragging in node:child_process / spawn /
6
+ * mkdtemp from the rest of codexAuth.ts. Used by:
7
+ * - action/utils/codexAuth.ts (re-exports refreshCodexAuthBody)
8
+ * - utils/codexSecretRotation.ts (server-side maybeRotate at run-context)
9
+ *
10
+ * See wiki/codex-auth.md for the end-to-end refresh lifecycle.
11
+ */
12
+
13
+ export interface CodexAuthBody {
14
+ auth_mode: "chatgpt";
15
+ tokens: {
16
+ access_token: string;
17
+ refresh_token: string;
18
+ id_token?: string;
19
+ account_id?: string;
20
+ };
21
+ last_refresh?: string;
22
+ }
23
+
24
+ /** OAuth client id Codex CLI and OpenCode both use against `auth.openai.com`.
25
+ * Same chain — a refresh token minted via `codex login --device-auth` can be
26
+ * refreshed against this client_id. */
27
+ export const CODEX_OAUTH_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
28
+ export const CODEX_OAUTH_TOKEN_URL = "https://auth.openai.com/oauth/token";
29
+
30
+ interface OAuthTokenResponse {
31
+ access_token: string;
32
+ refresh_token: string;
33
+ id_token?: string;
34
+ expires_in?: number;
35
+ }
36
+
37
+ /** thrown when the OAuth provider rejects the refresh token (4xx). callers
38
+ * can distinguish "race-lost / token revoked" from network errors via
39
+ * `instanceof OAuthInvalidGrantError`. */
40
+ export class OAuthInvalidGrantError extends Error {
41
+ public readonly status: number;
42
+ constructor(status: number, body: string) {
43
+ super(`Codex token refresh failed: ${status} ${body}`);
44
+ this.name = "OAuthInvalidGrantError";
45
+ this.status = status;
46
+ }
47
+ }
48
+
49
+ /** force one refresh round-trip against the OAuth provider. returns the
50
+ * rotated Codex-shaped blob (the auth.json body verbatim). does NOT persist
51
+ * — caller is responsible for writing back to wherever the token lives.
52
+ *
53
+ * server-side callers (maybeRotateCodexSecret) hold a DB row lock around
54
+ * this call so concurrent runs serialize: first one rotates, subsequent
55
+ * ones see the fresh value and skip. The 10s timeout is critical for that
56
+ * use: it caps how long a stalled auth.openai.com holds the row lock,
57
+ * keeping us well under the enclosing 30s transaction budget so the lock
58
+ * always releases and queued callers get a turn instead of timing out on
59
+ * the tx wrapper. Real OAuth latency is sub-second; 10s is generous. */
60
+ export async function refreshCodexAuthBody(body: CodexAuthBody): Promise<CodexAuthBody> {
61
+ const response = await fetch(CODEX_OAUTH_TOKEN_URL, {
62
+ method: "POST",
63
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
64
+ body: new URLSearchParams({
65
+ grant_type: "refresh_token",
66
+ refresh_token: body.tokens.refresh_token,
67
+ client_id: CODEX_OAUTH_CLIENT_ID,
68
+ }).toString(),
69
+ signal: AbortSignal.timeout(10_000),
70
+ });
71
+ if (!response.ok) {
72
+ const text = await response.text().catch(() => "");
73
+ if (response.status >= 400 && response.status < 500) {
74
+ throw new OAuthInvalidGrantError(response.status, text);
75
+ }
76
+ throw new Error(`Codex token refresh failed: ${response.status} ${text}`);
77
+ }
78
+ const tokens = (await response.json()) as OAuthTokenResponse;
79
+ const idToken = tokens.id_token ?? body.tokens.id_token;
80
+ const accountId = body.tokens.account_id;
81
+ return {
82
+ auth_mode: "chatgpt",
83
+ tokens: {
84
+ access_token: tokens.access_token,
85
+ refresh_token: tokens.refresh_token,
86
+ ...(idToken ? { id_token: idToken } : {}),
87
+ ...(accountId ? { account_id: accountId } : {}),
88
+ },
89
+ last_refresh: new Date().toISOString(),
90
+ };
91
+ }
92
+
93
+ /** decode the access_token's JWT payload and return its `exp` claim in ms
94
+ * since epoch. returns null if the token isn't a parseable JWT or has no
95
+ * `exp` claim — caller falls back to "treat as expired".
96
+ *
97
+ * We don't verify the JWT signature (we'd need OpenAI's JWKS); we're only
98
+ * using the claim as a freshness hint. The actual auth check happens
99
+ * server-side at OpenAI when the token is used — trusting a fake JWT here
100
+ * would just delay the inevitable 401 from OpenAI. No security boundary
101
+ * at this decode step. */
102
+ export function decodeJwtExpMs(token: string): number | null {
103
+ const parts = token.split(".");
104
+ if (parts.length !== 3) return null;
105
+ let payload: { exp?: unknown };
106
+ try {
107
+ payload = JSON.parse(Buffer.from(parts[1]!, "base64url").toString("utf8"));
108
+ } catch {
109
+ return null;
110
+ }
111
+ if (typeof payload.exp !== "number" || !Number.isFinite(payload.exp)) return null;
112
+ return payload.exp * 1000;
113
+ }
114
+
115
+ /** parse + validate a Codex auth.json body from its JSON-string form.
116
+ * returns null on any shape mismatch — caller treats as "no codex auth". */
117
+ export function parseCodexAuthBody(raw: string): CodexAuthBody | null {
118
+ let parsed: unknown;
119
+ try {
120
+ parsed = JSON.parse(raw);
121
+ } catch {
122
+ return null;
123
+ }
124
+ if (!parsed || typeof parsed !== "object") return null;
125
+ const v = parsed as Record<string, unknown>;
126
+ if (v.auth_mode !== "chatgpt") return null;
127
+ const tokens = v.tokens;
128
+ if (!tokens || typeof tokens !== "object") return null;
129
+ const t = tokens as Record<string, unknown>;
130
+ if (typeof t.access_token !== "string" || t.access_token.length === 0) return null;
131
+ if (typeof t.refresh_token !== "string" || t.refresh_token.length === 0) return null;
132
+ return {
133
+ auth_mode: "chatgpt",
134
+ tokens: {
135
+ access_token: t.access_token,
136
+ refresh_token: t.refresh_token,
137
+ ...(typeof t.id_token === "string" ? { id_token: t.id_token } : {}),
138
+ ...(typeof t.account_id === "string" ? { account_id: t.account_id } : {}),
139
+ },
140
+ ...(typeof v.last_refresh === "string" ? { last_refresh: v.last_refresh } : {}),
141
+ };
142
+ }
143
+
144
+ /** serialize a CodexAuthBody to its canonical on-disk form. */
145
+ export function stringifyCodexAuthBody(body: CodexAuthBody): string {
146
+ return `${JSON.stringify(body, null, 2)}\n`;
147
+ }
@@ -0,0 +1,85 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { detectCodexRefresh } from "#app/utils/codexRefreshDetect";
3
+
4
+ // installCodexAuth touches the filesystem (mkdir + writeFile) — leaving it
5
+ // untested here per AGENTS.md guidance ("be highly dubious of any test that
6
+ // relies on mocks"). The conversion math is what we actually want to
7
+ // protect; the disk write is one writeFileSync call.
8
+
9
+ describe("detectCodexRefresh", () => {
10
+ const original = "rt_original_chain";
11
+
12
+ it("returns Codex-shape JSON when openai.refresh advanced", () => {
13
+ const authFileContent = JSON.stringify({
14
+ openai: {
15
+ type: "oauth",
16
+ refresh: "rt_new_chain",
17
+ access: "at_new",
18
+ expires: 9_999_999_999_999,
19
+ accountId: "acc_123",
20
+ },
21
+ });
22
+ const result = detectCodexRefresh({ authFileContent, originalRefresh: original });
23
+ expect(result).not.toBeNull();
24
+ const parsed = JSON.parse(result ?? "{}");
25
+ expect(parsed.auth_mode).toBe("chatgpt");
26
+ expect(parsed.tokens.refresh_token).toBe("rt_new_chain");
27
+ expect(parsed.tokens.access_token).toBe("at_new");
28
+ expect(parsed.tokens.account_id).toBe("acc_123");
29
+ expect(typeof parsed.last_refresh).toBe("string");
30
+ });
31
+
32
+ it("omits account_id when accountId is absent from OpenCode shape", () => {
33
+ const authFileContent = JSON.stringify({
34
+ openai: {
35
+ type: "oauth",
36
+ refresh: "rt_new",
37
+ access: "at_new",
38
+ expires: 0,
39
+ },
40
+ });
41
+ const result = detectCodexRefresh({ authFileContent, originalRefresh: original });
42
+ const parsed = JSON.parse(result ?? "{}");
43
+ expect("account_id" in parsed.tokens).toBe(false);
44
+ });
45
+
46
+ it("returns null when refresh token unchanged (no rotation happened)", () => {
47
+ const authFileContent = JSON.stringify({
48
+ openai: { type: "oauth", refresh: original, access: "at_same", expires: 0 },
49
+ });
50
+ expect(detectCodexRefresh({ authFileContent, originalRefresh: original })).toBeNull();
51
+ });
52
+
53
+ it("returns null when openai entry is missing", () => {
54
+ const authFileContent = JSON.stringify({
55
+ anthropic: { type: "oauth", refresh: "rt_other", access: "at_other", expires: 0 },
56
+ });
57
+ expect(detectCodexRefresh({ authFileContent, originalRefresh: original })).toBeNull();
58
+ });
59
+
60
+ it("returns null when openai is api-key type (no refresh chain)", () => {
61
+ const authFileContent = JSON.stringify({
62
+ openai: { type: "api", key: "sk-something" },
63
+ });
64
+ expect(detectCodexRefresh({ authFileContent, originalRefresh: original })).toBeNull();
65
+ });
66
+
67
+ it("returns null for malformed JSON", () => {
68
+ expect(
69
+ detectCodexRefresh({ authFileContent: "{not json", originalRefresh: original }),
70
+ ).toBeNull();
71
+ });
72
+
73
+ it("returns null for non-object content", () => {
74
+ expect(
75
+ detectCodexRefresh({ authFileContent: '"a string"', originalRefresh: original }),
76
+ ).toBeNull();
77
+ });
78
+
79
+ it("returns null when refresh field is missing", () => {
80
+ const authFileContent = JSON.stringify({
81
+ openai: { type: "oauth", access: "at_new", expires: 0 },
82
+ });
83
+ expect(detectCodexRefresh({ authFileContent, originalRefresh: original })).toBeNull();
84
+ });
85
+ });
@@ -0,0 +1,35 @@
1
+ /** Convert an on-disk OpenCode auth.json back to the Codex CLI shape so the
2
+ * post-hook can write it to the Terramend secret store. Returns null when the
3
+ * file's `openai` entry is missing, has the wrong type, or hasn't actually
4
+ * refreshed (refresh token unchanged from `originalRefresh`). Lives in its
5
+ * own module so `entryPost.ts` can import it without pulling in `codexHome.ts`
6
+ * (which imports `./cli.ts` and node fs helpers). */
7
+ export function detectCodexRefresh(params: {
8
+ authFileContent: string;
9
+ originalRefresh: string;
10
+ }): string | null {
11
+ let parsed: unknown;
12
+ try {
13
+ parsed = JSON.parse(params.authFileContent);
14
+ } catch {
15
+ return null;
16
+ }
17
+ if (!parsed || typeof parsed !== "object") return null;
18
+ const oauth = (parsed as Record<string, unknown>).openai;
19
+ if (!oauth || typeof oauth !== "object") return null;
20
+ const o = oauth as Record<string, unknown>;
21
+ if (o.type !== "oauth") return null;
22
+ if (typeof o.refresh !== "string" || typeof o.access !== "string") return null;
23
+ if (o.refresh === params.originalRefresh) return null;
24
+
25
+ const codexShape = {
26
+ auth_mode: "chatgpt",
27
+ tokens: {
28
+ access_token: o.access,
29
+ refresh_token: o.refresh,
30
+ ...(typeof o.accountId === "string" ? { account_id: o.accountId } : {}),
31
+ },
32
+ last_refresh: new Date().toISOString(),
33
+ };
34
+ return `${JSON.stringify(codexShape, null, 2)}\n`;
35
+ }