chainlesschain 0.162.122 → 0.162.124

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 (228) hide show
  1. package/package.json +1 -1
  2. package/src/assets/web-panel/.build-hash +1 -1
  3. package/src/assets/web-panel/assets/AIOps-BsDJlD_l.js +1 -0
  4. package/src/assets/web-panel/assets/{ActionButton-Bqvaz1Yj.js → ActionButton-COjZZKWG.js} +1 -1
  5. package/src/assets/web-panel/assets/Analytics-4ZDZE7lc.js +3 -0
  6. package/src/assets/web-panel/assets/{AppLayout-cs0TRZya.js → AppLayout-CYH5k2Rp.js} +5 -5
  7. package/src/assets/web-panel/assets/Audit-BKlFbLdl.js +1 -0
  8. package/src/assets/web-panel/assets/Backup-CiX61Qc2.js +1 -0
  9. package/src/assets/web-panel/assets/{BaseInput-4qhQiJfI.js → BaseInput-CDxIQokd.js} +1 -1
  10. package/src/assets/web-panel/assets/{Chat-DUFX3Mv4.js → Chat-CaKgdRab.js} +6 -6
  11. package/src/assets/web-panel/assets/ChatBubbleRenderer-DgvpwU5o.js +1 -0
  12. package/src/assets/web-panel/assets/{Checkbox-C4eyD_nB.js → Checkbox-CjiWfu4s.js} +1 -1
  13. package/src/assets/web-panel/assets/Codegen-tKWGzajw.js +1 -0
  14. package/src/assets/web-panel/assets/{Col-3eHStfvJ.js → Col-ZKWREyNs.js} +1 -1
  15. package/src/assets/web-panel/assets/{Community-DFbKjEgh.js → Community-CmLuPBUU.js} +1 -1
  16. package/src/assets/web-panel/assets/{Compact-D5str1h-.js → Compact-DqknM98n.js} +1 -1
  17. package/src/assets/web-panel/assets/Compliance-PZw0aBLI.js +1 -0
  18. package/src/assets/web-panel/assets/{Cowork-Cdliboj6.js → Cowork-Dp29kHsC.js} +2 -2
  19. package/src/assets/web-panel/assets/{Cron-Bfmwl6iZ.js → Cron-zmEJAetz.js} +2 -2
  20. package/src/assets/web-panel/assets/Crosschain-Sb-uM7Ty.js +1 -0
  21. package/src/assets/web-panel/assets/{DID-xqi72kjo.js → DID-D_UFNzJ5.js} +2 -2
  22. package/src/assets/web-panel/assets/{Dashboard-4l68QdSQ.js → Dashboard-Btag698v.js} +2 -2
  23. package/src/assets/web-panel/assets/{Dropdown-C2wsZOdn.js → Dropdown-CH7l3KCs.js} +1 -1
  24. package/src/assets/web-panel/assets/EmailListRenderer-Bud2M3XX.js +1 -0
  25. package/src/assets/web-panel/assets/{FamilyGuardDashboard-CHK5rapA.js → FamilyGuardDashboard-Bd2_8uME.js} +1 -1
  26. package/src/assets/web-panel/assets/Federation-1jhstF5P.js +1 -0
  27. package/src/assets/web-panel/assets/{FormItemContext-lLOEHvAb.js → FormItemContext-CpSH464Y.js} +1 -1
  28. package/src/assets/web-panel/assets/{GenericCardRenderer-CaDh2XiY.js → GenericCardRenderer-CANo8O-y.js} +1 -1
  29. package/src/assets/web-panel/assets/{Git-B9SmGPMU.js → Git-hvuZVoD3.js} +2 -2
  30. package/src/assets/web-panel/assets/Governance-BQrWksKt.js +1 -0
  31. package/src/assets/web-panel/assets/Inference-jEBTp12v.js +1 -0
  32. package/src/assets/web-panel/assets/KnowledgeGraph-tDiV2taf.js +1 -0
  33. package/src/assets/web-panel/assets/Logs-DDkTnedu.js +2 -0
  34. package/src/assets/web-panel/assets/Marketplace-9Yi32avt.js +1 -0
  35. package/src/assets/web-panel/assets/{McpTools-Dxlypa0R.js → McpTools-Qx78XyOT.js} +5 -5
  36. package/src/assets/web-panel/assets/{Memory-BxBHR7eW.js → Memory-CXYDO1oT.js} +2 -2
  37. package/src/assets/web-panel/assets/MobileBridge-MfUfkHA0.js +1 -0
  38. package/src/assets/web-panel/assets/{MobileProjects-59gyfK6T.js → MobileProjects-DEDbWNIk.js} +1 -1
  39. package/src/assets/web-panel/assets/{Mtc-2YBBawFi.js → Mtc-Dwa_vlR8.js} +2 -2
  40. package/src/assets/web-panel/assets/{MtcAudit-DczAeHfM.js → MtcAudit-DAVkxhJv.js} +2 -2
  41. package/src/assets/web-panel/assets/{Multisig-CPxyt7a-.js → Multisig-C3upmgPN.js} +3 -3
  42. package/src/assets/web-panel/assets/NLProgramming-DO3CZ0_z.js +1 -0
  43. package/src/assets/web-panel/assets/{Notes-tHRMLOQ3.js → Notes-B1Oy3FbC.js} +4 -4
  44. package/src/assets/web-panel/assets/{NotificationSettings-DxIYUWPW.js → NotificationSettings-Bs4-Fc6b.js} +1 -1
  45. package/src/assets/web-panel/assets/OrderTableRenderer-CEy1pIeq.js +1 -0
  46. package/src/assets/web-panel/assets/Organization-D4cJ1UNo.js +4 -0
  47. package/src/assets/web-panel/assets/{Overflow-DrdVw_fC.js → Overflow-fdzvlF7x.js} +1 -1
  48. package/src/assets/web-panel/assets/{P2P-CMBYxejX.js → P2P-Y13PwXBA.js} +2 -2
  49. package/src/assets/web-panel/assets/PdhVaultBrowser-Ck1zCLe6.js +7 -0
  50. package/src/assets/web-panel/assets/Permissions-CvEXP6LC.js +4 -0
  51. package/src/assets/web-panel/assets/{PersonalDataHub-nuqNTK2w.js → PersonalDataHub-JeP7IWMW.js} +3 -3
  52. package/src/assets/web-panel/assets/Pipeline-BZ7wRzG1.js +1 -0
  53. package/src/assets/web-panel/assets/Privacy-BJ6qj9Hv.js +1 -0
  54. package/src/assets/web-panel/assets/{ProjectInit-hCWXNnGJ.js → ProjectInit-DeWd9UcS.js} +2 -2
  55. package/src/assets/web-panel/assets/ProjectSettings-DUpVuU9F.js +2 -0
  56. package/src/assets/web-panel/assets/{Projects-C6l6z4Mo.js → Projects-y1WbI337.js} +1 -1
  57. package/src/assets/web-panel/assets/{Providers-kEifEWLb.js → Providers-DECW00ek.js} +1 -1
  58. package/src/assets/web-panel/assets/{QuickAsk-B6oLUYKR.js → QuickAsk-Dl-pK9Io.js} +1 -1
  59. package/src/assets/web-panel/assets/Recommend-Bju04wTd.js +1 -0
  60. package/src/assets/web-panel/assets/Reputation-BUPa3nEk.js +1 -0
  61. package/src/assets/web-panel/assets/{Row-W6LSmdzh.js → Row-WYqqaq_5.js} +1 -1
  62. package/src/assets/web-panel/assets/{RssFeed-DJi97lWh.js → RssFeed-iyQT5q9l.js} +3 -3
  63. package/src/assets/web-panel/assets/Search-CrfwJF0R.js +1 -0
  64. package/src/assets/web-panel/assets/{Security-BtawKhmm.js → Security-c4Jxf5Ih.js} +4 -4
  65. package/src/assets/web-panel/assets/{Services-BdCe11of.js → Services-CRuisolj.js} +2 -2
  66. package/src/assets/web-panel/assets/{Skeleton-C_wcDxpN.js → Skeleton-CCnzJkb-.js} +1 -1
  67. package/src/assets/web-panel/assets/{Skills-n2GgEfQ1.js → Skills-CZURCwZg.js} +1 -1
  68. package/src/assets/web-panel/assets/Sla-CrMzaEi2.js +1 -0
  69. package/src/assets/web-panel/assets/SpeechSettings-jTDOM5eY.js +1 -0
  70. package/src/assets/web-panel/assets/{SyncSettings-BBbldyeR.js → SyncSettings-CJWVtd87.js} +2 -2
  71. package/src/assets/web-panel/assets/{Tasks-DMkBOCW-.js → Tasks-61lrQ_lS.js} +1 -1
  72. package/src/assets/web-panel/assets/Templates-Cg-iF2g1.js +1 -0
  73. package/src/assets/web-panel/assets/Tenant-DrDv1FFc.js +1 -0
  74. package/src/assets/web-panel/assets/Terminal-CP15hcNS.js +3 -0
  75. package/src/assets/web-panel/assets/{TimelineRenderer-Drf_B0al.js → TimelineRenderer-Sl5uXrkN.js} +1 -1
  76. package/src/assets/web-panel/assets/Tokens-yRIZTVsR.js +1 -0
  77. package/src/assets/web-panel/assets/{Trigger-CNUB_qRQ.js → Trigger-BLCQEOF2.js} +1 -1
  78. package/src/assets/web-panel/assets/Trust-D5xq8z6s.js +1 -0
  79. package/src/assets/web-panel/assets/{UkeySign-7o1O51qV.js → UkeySign-Dwp0zXQ6.js} +1 -1
  80. package/src/assets/web-panel/assets/{VideoEditing-Ccb1cTxo.js → VideoEditing-CIHA55Sx.js} +1 -1
  81. package/src/assets/web-panel/assets/Wallet-Hjajvnto.js +4 -0
  82. package/src/assets/web-panel/assets/WebAuthn-DqSURUvI.js +5 -0
  83. package/src/assets/web-panel/assets/{WorkflowEditor-BcFvWc2d.js → WorkflowEditor-B5bHRwUG.js} +1 -1
  84. package/src/assets/web-panel/assets/{chat-CPvCSHY4.js → chat-DsheLKkG.js} +1 -1
  85. package/src/assets/web-panel/assets/{colors-Ca7MoLtb.js → colors-CST2UkgL.js} +1 -1
  86. package/src/assets/web-panel/assets/community-parser-Cuyslaku.js +3 -0
  87. package/src/assets/web-panel/assets/{compact-item-B9AUAVCQ.js → compact-item-yqEoy1L7.js} +1 -1
  88. package/src/assets/web-panel/assets/{createContext-CnMJHqHw.js → createContext-Jwp78HFY.js} +1 -1
  89. package/src/assets/web-panel/assets/devWarning-C8to6gQk.js +1 -0
  90. package/src/assets/web-panel/assets/{hasIn-Bevd8pXd.js → hasIn-CVS5mFEP.js} +1 -1
  91. package/src/assets/web-panel/assets/{index-gR1xGRvy.js → index-AnW25bEq.js} +1 -1
  92. package/src/assets/web-panel/assets/{index-B3zlqc60.js → index-BCHAUdel.js} +1 -1
  93. package/src/assets/web-panel/assets/{index-BzmhHe0m.js → index-BLTUVd0V.js} +1 -1
  94. package/src/assets/web-panel/assets/{index-BM1tI3g5.js → index-BeblNJDH.js} +1 -1
  95. package/src/assets/web-panel/assets/{index-CAKYScOz.js → index-BuI27WSx.js} +1 -1
  96. package/src/assets/web-panel/assets/{index-PidVLx0u.js → index-BzetOasL.js} +1 -1
  97. package/src/assets/web-panel/assets/{index-Dnk8TOm3.js → index-C0FZScMe.js} +1 -1
  98. package/src/assets/web-panel/assets/{index-CvhLkwFR.js → index-C130LLPq.js} +1 -1
  99. package/src/assets/web-panel/assets/{index-0DtNS6oi.js → index-C8rq85gu.js} +1 -1
  100. package/src/assets/web-panel/assets/{index-BUlsrbu2.js → index-CDAPnZUs.js} +1 -1
  101. package/src/assets/web-panel/assets/{index-HdGuA1pS.js → index-CIE2n8k_.js} +1 -1
  102. package/src/assets/web-panel/assets/{index-ecThTNhE.js → index-CRBbVS43.js} +1 -1
  103. package/src/assets/web-panel/assets/{index-DW3rTBy_.js → index-CSBPc-sI.js} +1 -1
  104. package/src/assets/web-panel/assets/{index-C5i4gKvN.js → index-CTd3lDxp.js} +1 -1
  105. package/src/assets/web-panel/assets/index-CUWj5L2s.js +1 -0
  106. package/src/assets/web-panel/assets/{index-B619_m_T.js → index-ChRL6rgA.js} +1 -1
  107. package/src/assets/web-panel/assets/{index-tUD_CWx-.js → index-CjfN2CpJ.js} +1 -1
  108. package/src/assets/web-panel/assets/{index-BcRDjaEi.js → index-Cjig5i3a.js} +3 -3
  109. package/src/assets/web-panel/assets/{index-Ct3wP67S.js → index-Crk4KWLW.js} +1 -1
  110. package/src/assets/web-panel/assets/{index-p1rI3d66.js → index-CrlRa054.js} +1 -1
  111. package/src/assets/web-panel/assets/{index-DPk2HXTA.js → index-CtyEOGuj.js} +1 -1
  112. package/src/assets/web-panel/assets/{index-aB7GLimB.js → index-Cu6Ql64n.js} +1 -1
  113. package/src/assets/web-panel/assets/{index-BFkrnvs_.js → index-Cx_t5rWw.js} +1 -1
  114. package/src/assets/web-panel/assets/{index-ienIjr7_.js → index-Cy0dNzkp.js} +1 -1
  115. package/src/assets/web-panel/assets/{index-DAjYLhik.js → index-CzsKK0tQ.js} +1 -1
  116. package/src/assets/web-panel/assets/{index-CGEc6vlv.js → index-DDAigsel.js} +1 -1
  117. package/src/assets/web-panel/assets/index-DZfnYE6e.js +1 -0
  118. package/src/assets/web-panel/assets/{index-C3biz3RB.js → index-D_1b7uh6.js} +1 -1
  119. package/src/assets/web-panel/assets/{index-D9VR1tES.js → index-D_e9gwfn.js} +1 -1
  120. package/src/assets/web-panel/assets/{index-BGBbFzIJ.js → index-DbxAlpPC.js} +1 -1
  121. package/src/assets/web-panel/assets/{index-CVxhWXMd.js → index-De600Jjm.js} +1 -1
  122. package/src/assets/web-panel/assets/{index-2KMpdEzs.js → index-Dgyn8-wN.js} +1 -1
  123. package/src/assets/web-panel/assets/{index-CQKVKyjf.js → index-DiK4vSZa.js} +1 -1
  124. package/src/assets/web-panel/assets/{index-CtIv89mI.js → index-DpYn5v2k.js} +1 -1
  125. package/src/assets/web-panel/assets/{index-DTxmgfz2.js → index-DxaU_lza.js} +1 -1
  126. package/src/assets/web-panel/assets/{index-Cd62bwRd.js → index-Dxljh9Fu.js} +1 -1
  127. package/src/assets/web-panel/assets/{index-kUeSjyHt.js → index-R-VGFpi6.js} +1 -1
  128. package/src/assets/web-panel/assets/{index-BwDJvHfR.js → index-SjxCCf5h.js} +1 -1
  129. package/src/assets/web-panel/assets/{index-BV1U7tR6.js → index-diWkx2Wq.js} +1 -1
  130. package/src/assets/web-panel/assets/{initDefaultProps-dsKHYCPH.js → initDefaultProps-ClAjrsQ-.js} +1 -1
  131. package/src/assets/web-panel/assets/{motion-DdPCK2zh.js → motion-BalXv_60.js} +1 -1
  132. package/src/assets/web-panel/assets/{move-Bi0bmOjg.js → move-DMelzIW-.js} +1 -1
  133. package/src/assets/web-panel/assets/mtc-parser-CEQffxds.js +1 -0
  134. package/src/assets/web-panel/assets/{omit-DCrBiySU.js → omit-oxecfU3b.js} +1 -1
  135. package/src/assets/web-panel/assets/{pickAttrs-CcJuqfya.js → pickAttrs-DJ5F5M6x.js} +1 -1
  136. package/src/assets/web-panel/assets/{placementArrow-DFGuzth-.js → placementArrow-DdbKsant.js} +1 -1
  137. package/src/assets/web-panel/assets/{responsiveObserve-B_WDlgvI.js → responsiveObserve-BUuM5cmS.js} +1 -1
  138. package/src/assets/web-panel/assets/{slide-BSFabVgO.js → slide-DSV0ccvR.js} +1 -1
  139. package/src/assets/web-panel/assets/{statusUtils-CebvsGH8.js → statusUtils-B-6oLAXf.js} +1 -1
  140. package/src/assets/web-panel/assets/{styleChecker-CMuE7NWy.js → styleChecker-DdCAHtsZ.js} +1 -1
  141. package/src/assets/web-panel/assets/{useFlexGapSupport-RWGVgZNu.js → useFlexGapSupport-ZORkcoZd.js} +1 -1
  142. package/src/assets/web-panel/assets/{useFs-srxzzfG_.js → useFs-BFp46LoP.js} +1 -1
  143. package/src/assets/web-panel/assets/{usePersonalDataHub-B5OwxZuK.js → usePersonalDataHub-H7rxgbdW.js} +1 -1
  144. package/src/assets/web-panel/assets/{vnode-CXOEebAH.js → vnode-eIq4Tk0J.js} +1 -1
  145. package/src/assets/web-panel/assets/{zoom-DKNwqG2v.js → zoom-CTNrv8-H.js} +1 -1
  146. package/src/assets/web-panel/index.html +1 -1
  147. package/src/commands/ask.js +24 -1
  148. package/src/commands/init.js +5 -4
  149. package/src/commands/instinct.js +18 -5
  150. package/src/commands/mcp.js +30 -3
  151. package/src/commands/memory.js +18 -5
  152. package/src/gateways/http/envelope-http-server.js +17 -2
  153. package/src/gateways/terminal/PtyManager.js +15 -2
  154. package/src/gateways/ws/ws-server.js +20 -0
  155. package/src/gateways/ws/ws-session-gateway.js +16 -0
  156. package/src/harness/mcp-client.js +124 -6
  157. package/src/harness/worktree-isolator.js +20 -9
  158. package/src/lib/agent-core.js +3 -0
  159. package/src/lib/autonomous-agent.js +7 -6
  160. package/src/lib/chat-intent-service.js +4 -2
  161. package/src/lib/checkpoint-store.js +23 -0
  162. package/src/lib/credential-guard.js +291 -0
  163. package/src/lib/ensure-utf8.js +6 -1
  164. package/src/lib/git-integration.js +57 -1
  165. package/src/lib/hook-manager.js +15 -6
  166. package/src/lib/hook-runner.cjs +11 -1
  167. package/src/lib/interactive-planner.js +4 -3
  168. package/src/lib/json-schema-output.js +47 -0
  169. package/src/lib/learning/reflection-engine.js +4 -3
  170. package/src/lib/learning/skill-improver.js +4 -3
  171. package/src/lib/learning/skill-synthesizer.js +4 -3
  172. package/src/lib/mcp-client.js +1 -0
  173. package/src/lib/mcp-oauth.js +243 -24
  174. package/src/lib/mcp-serve.js +56 -6
  175. package/src/lib/orchestrator.js +4 -2
  176. package/src/lib/process-manager.js +31 -3
  177. package/src/lib/repl-completer.js +29 -0
  178. package/src/lib/repl-rewind.js +58 -0
  179. package/src/lib/repl-vim.js +13 -1
  180. package/src/lib/settings-hooks.cjs +12 -3
  181. package/src/lib/slot-filler.js +4 -3
  182. package/src/lib/sub-agent-context.js +6 -0
  183. package/src/lib/web-fetch.js +54 -3
  184. package/src/lib/workflow-engine.js +35 -11
  185. package/src/repl/agent-repl.js +52 -2
  186. package/src/runtime/agent-core.js +324 -26
  187. package/src/runtime/headless-stream.js +124 -17
  188. package/src/assets/web-panel/assets/AIOps-BmS46GrQ.js +0 -1
  189. package/src/assets/web-panel/assets/Analytics-BhCnMbKz.js +0 -3
  190. package/src/assets/web-panel/assets/Audit-AwLNQzvX.js +0 -1
  191. package/src/assets/web-panel/assets/Backup-BOglo5g6.js +0 -1
  192. package/src/assets/web-panel/assets/ChatBubbleRenderer-LWgyWUF_.js +0 -1
  193. package/src/assets/web-panel/assets/Codegen-B9SRqAqh.js +0 -1
  194. package/src/assets/web-panel/assets/Compliance-DDYPQDiG.js +0 -1
  195. package/src/assets/web-panel/assets/Crosschain-Bfo6RvSg.js +0 -1
  196. package/src/assets/web-panel/assets/EmailListRenderer-CWh_fKmI.js +0 -1
  197. package/src/assets/web-panel/assets/Federation-JpiE2iKR.js +0 -1
  198. package/src/assets/web-panel/assets/Governance-B3ODoSO2.js +0 -1
  199. package/src/assets/web-panel/assets/Inference-C_jP_nlz.js +0 -1
  200. package/src/assets/web-panel/assets/KnowledgeGraph-zYwypnwu.js +0 -1
  201. package/src/assets/web-panel/assets/Logs-D6owai5o.js +0 -2
  202. package/src/assets/web-panel/assets/Marketplace-umRB80w4.js +0 -1
  203. package/src/assets/web-panel/assets/MobileBridge-Car9kLtE.js +0 -3
  204. package/src/assets/web-panel/assets/NLProgramming-DvKJ8imu.js +0 -1
  205. package/src/assets/web-panel/assets/OrderTableRenderer-BMpVfQkg.js +0 -1
  206. package/src/assets/web-panel/assets/Organization-BBsie-kN.js +0 -4
  207. package/src/assets/web-panel/assets/PdhVaultBrowser-BBHzSaeq.js +0 -7
  208. package/src/assets/web-panel/assets/Permissions-W4kD06Cj.js +0 -4
  209. package/src/assets/web-panel/assets/Pipeline-DzCX3yHy.js +0 -1
  210. package/src/assets/web-panel/assets/Privacy-9ENAJQOC.js +0 -1
  211. package/src/assets/web-panel/assets/ProjectSettings--BzvEaxE.js +0 -2
  212. package/src/assets/web-panel/assets/Recommend-BIEMGN0q.js +0 -1
  213. package/src/assets/web-panel/assets/Reputation-D-9AEaRr.js +0 -1
  214. package/src/assets/web-panel/assets/Search-DOEB2utf.js +0 -1
  215. package/src/assets/web-panel/assets/Sla-D5WfrvLP.js +0 -1
  216. package/src/assets/web-panel/assets/SpeechSettings-D6qCWpan.js +0 -1
  217. package/src/assets/web-panel/assets/Templates-DbLImR1I.js +0 -1
  218. package/src/assets/web-panel/assets/Tenant-B0qM4w6Z.js +0 -1
  219. package/src/assets/web-panel/assets/Terminal-BjKUkiR1.js +0 -3
  220. package/src/assets/web-panel/assets/Tokens-DgCx2Bfa.js +0 -1
  221. package/src/assets/web-panel/assets/Trust-JQUTjXXI.js +0 -1
  222. package/src/assets/web-panel/assets/Wallet-EMf9MlLK.js +0 -4
  223. package/src/assets/web-panel/assets/WebAuthn-DOYoaCmi.js +0 -5
  224. package/src/assets/web-panel/assets/community-parser-CO25nZFt.js +0 -3
  225. package/src/assets/web-panel/assets/devWarning-vFgm_Ssk.js +0 -1
  226. package/src/assets/web-panel/assets/index-BGUjPh7h.js +0 -1
  227. package/src/assets/web-panel/assets/index-Cx-wP5XE.js +0 -1
  228. package/src/assets/web-panel/assets/mtc-parser-BbAbUB-9.js +0 -1
@@ -2,7 +2,7 @@
2
2
  * Git integration — wraps system git commands for knowledge base versioning.
3
3
  */
4
4
 
5
- import { execSync } from "child_process";
5
+ import { execSync, spawnSync } from "child_process";
6
6
  import { existsSync, writeFileSync, chmodSync } from "fs";
7
7
  import { join } from "path";
8
8
 
@@ -41,6 +41,62 @@ export function assertSafeGitRef(ref, label = "git ref") {
41
41
  }
42
42
  }
43
43
 
44
+ /**
45
+ * Validate a repo-relative file path before it is interpolated into a shell git
46
+ * command (e.g. `diff … -- "${filePath}"`). Double-quoting does NOT neutralize
47
+ * `$()` / backticks / a closing `"`, so an unsanitized path from a WS frame is a
48
+ * shell-injection vector. Allow only ordinary path characters (no shell
49
+ * metacharacters), reject absolute paths, a leading `-` (flag injection), and
50
+ * `..` (traversal). Exported for reuse + tests.
51
+ *
52
+ * @param {string} p
53
+ * @param {string} [label="file path"]
54
+ */
55
+ export function assertSafeGitPath(p, label = "file path") {
56
+ if (typeof p !== "string" || p.length === 0) {
57
+ throw new Error(`Invalid ${label}: expected a non-empty string`);
58
+ }
59
+ if (
60
+ !/^[A-Za-z0-9._/+@=, -]+$/.test(p) || // safe path chars only — no shell metachars
61
+ p.startsWith("-") || // no flag injection (e.g. `-- --output=…`)
62
+ p.startsWith("/") || // no absolute path (posix)
63
+ /^[A-Za-z]:/.test(p) || // no absolute path (windows drive)
64
+ p.includes("..") // no traversal / range tricks
65
+ ) {
66
+ throw new Error(`Unsafe ${label}: ${JSON.stringify(p)}`);
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Run git with an ARGV array (no shell) — for commands that carry free-text the
72
+ * caller can't allowlist (a commit / merge message). spawnSync passes each arg
73
+ * verbatim, so `$()`, backticks, quotes, and `;` in the message are inert
74
+ * instead of being interpreted by a shell as they would be through `gitExec`.
75
+ * Throws git's stderr on failure, mirroring `gitExec`'s contract.
76
+ *
77
+ * @param {string[]} args
78
+ * @param {string} cwd
79
+ * @param {object} [opts] { input }
80
+ * @returns {string} trimmed stdout
81
+ */
82
+ export function gitExecArgs(args, cwd, opts = {}) {
83
+ const res = spawnSync("git", args, {
84
+ cwd,
85
+ encoding: "utf-8",
86
+ windowsHide: true,
87
+ maxBuffer: 64 * 1024 * 1024,
88
+ input: opts.input,
89
+ });
90
+ if (res.error) throw res.error;
91
+ if (res.status !== 0) {
92
+ const stderr = (res.stderr || res.stdout || "").toString().trim();
93
+ throw new Error(
94
+ stderr || `git ${args.join(" ")} failed (exit ${res.status})`,
95
+ );
96
+ }
97
+ return (res.stdout || "").toString().trim();
98
+ }
99
+
44
100
  /**
45
101
  * Run a git command and return stdout.
46
102
  */
@@ -208,7 +208,7 @@ export function getHook(db, hookId) {
208
208
  * Compile a matcher pattern into a test function.
209
209
  * Supports:
210
210
  * - null/undefined → matches everything
211
- * - Pipe-separated patterns: "Edit|Write" matches "Edit" or "Write"
211
+ * - Pipe- OR comma-separated patterns: "Edit|Write" / "Bash,PowerShell"
212
212
  * - Wildcards: "*" matches any chars, "?" matches one char
213
213
  * - Regex strings starting with "/": "/^Pre/" matches "PreIPCCall"
214
214
  */
@@ -230,11 +230,20 @@ export function compileMatcher(pattern) {
230
230
  }
231
231
  }
232
232
 
233
- // Pipe-separated patterns (e.g. "Edit|Write")
234
- if (pattern.includes("|")) {
235
- const parts = pattern.split("|").map((p) => p.trim());
236
- const matchers = parts.map((p) => compileMatcher(p));
237
- return (value) => matchers.some((m) => m(value));
233
+ // OR-separated alternatives: pipe "Edit|Write" AND comma "Bash,PowerShell"
234
+ // (Claude-Code 2.1.191: comma-separated matchers silently never fired). Empty
235
+ // segments (trailing "Bash," / "Edit|") are dropped so they cannot collapse
236
+ // into a match-everything matcher. Regex "/…/" matchers are handled above, so
237
+ // a comma inside "{1,3}" is never split here.
238
+ if (pattern.includes("|") || pattern.includes(",")) {
239
+ const matchers = pattern
240
+ .split(/[|,]/)
241
+ .map((p) => p.trim())
242
+ .filter(Boolean)
243
+ .map((p) => compileMatcher(p));
244
+ return matchers.length
245
+ ? (value) => matchers.some((m) => m(value))
246
+ : () => false;
238
247
  }
239
248
 
240
249
  // Wildcard pattern (* and ?)
@@ -107,7 +107,17 @@ function tryParseDecision(stdout) {
107
107
  * nonBlockingError?:boolean, error?:string }}
108
108
  */
109
109
  function runCommandHook(command, input = {}, opts = {}) {
110
- const { timeout = 60000, cwd, event } = opts;
110
+ const { cwd, event } = opts;
111
+ // spawnSync is SYNCHRONOUS — it blocks the whole agent until the hook returns
112
+ // or its timeout fires. Node treats a 0/undefined timeout as UNLIMITED, so a
113
+ // missing, zero, NaN, or negative value (e.g. a malformed settings.json
114
+ // `timeout`) would let a hung hook freeze the agent forever. Guarantee a
115
+ // positive, bounded timeout regardless of the caller's value (already in ms).
116
+ const _rawTimeout = Number(opts.timeout);
117
+ const timeout =
118
+ Number.isFinite(_rawTimeout) && _rawTimeout > 0
119
+ ? Math.min(_rawTimeout, 600000)
120
+ : 60000;
111
121
  if (!command) {
112
122
  return { decision: HOOK_DECISIONS.CONTINUE, reason: null, exitCode: 0 };
113
123
  }
@@ -9,6 +9,7 @@
9
9
 
10
10
  import { EventEmitter } from "events";
11
11
  import { createHash } from "crypto";
12
+ import { firstBalancedJson } from "./json-schema-output.js";
12
13
 
13
14
  /**
14
15
  * Plan session statuses
@@ -345,12 +346,12 @@ Keep plans concise (3-8 steps). Use appropriate tools for each step.`;
345
346
  ]);
346
347
 
347
348
  const content = response?.message?.content || response?.content || "";
348
- const jsonMatch = content.match(/\{[\s\S]*\}/);
349
- if (!jsonMatch) {
349
+ const jsonText = firstBalancedJson(content, "{");
350
+ if (!jsonText) {
350
351
  throw new Error("Failed to parse plan from LLM response");
351
352
  }
352
353
 
353
- return JSON.parse(jsonMatch[0]);
354
+ return JSON.parse(jsonText);
354
355
  }
355
356
 
356
357
  /**
@@ -80,6 +80,48 @@ export function validateAgainstSchema(value, schema, path = "$") {
80
80
  return errors;
81
81
  }
82
82
 
83
+ /**
84
+ * Return the first *balanced* JSON object/array substring in `text`, honoring
85
+ * string literals so braces inside strings don't throw off the depth count.
86
+ *
87
+ * Unlike a greedy `/\{[\s\S]*\}/` match (which spans to the LAST bracket and
88
+ * over-captures trailing prose or a second object — making JSON.parse throw),
89
+ * this stops at the first complete top-level value.
90
+ *
91
+ * @param {string} text
92
+ * @param {"{"|"["} [only] restrict to object- or array-openers when set
93
+ * @returns {string|null} the balanced substring, or null if none is found
94
+ */
95
+ export function firstBalancedJson(text, only) {
96
+ const s = String(text || "");
97
+ for (let i = 0; i < s.length; i++) {
98
+ const open = s[i];
99
+ if (open !== "{" && open !== "[") continue;
100
+ if (only && open !== only) continue;
101
+ const close = open === "{" ? "}" : "]";
102
+ let depth = 0;
103
+ let inStr = false;
104
+ let esc = false;
105
+ for (let j = i; j < s.length; j++) {
106
+ const c = s[j];
107
+ if (inStr) {
108
+ if (esc) esc = false;
109
+ else if (c === "\\") esc = true;
110
+ else if (c === '"') inStr = false;
111
+ continue;
112
+ }
113
+ if (c === '"') inStr = true;
114
+ else if (c === open) depth++;
115
+ else if (c === close) {
116
+ depth--;
117
+ if (depth === 0) return s.slice(i, j + 1);
118
+ }
119
+ }
120
+ // opener at i never closed — try the next opener
121
+ }
122
+ return null;
123
+ }
124
+
83
125
  /** Pull a JSON payload out of an LLM reply (bare, fenced, or embedded). */
84
126
  export function extractJsonPayload(text) {
85
127
  const raw = String(text || "").trim();
@@ -87,6 +129,11 @@ export function extractJsonPayload(text) {
87
129
  tries.push(raw);
88
130
  const fence = /```(?:json)?\s*([\s\S]*?)```/i.exec(raw);
89
131
  if (fence) tries.push(fence[1].trim());
132
+ // Prefer a balanced run (stops at the first complete value) over the greedy
133
+ // first-bracket..last-bracket slice below, which over-captures on trailing
134
+ // prose or a second object.
135
+ const balanced = firstBalancedJson(raw);
136
+ if (balanced) tries.push(balanced);
90
137
  const firstObj = raw.indexOf("{");
91
138
  const lastObj = raw.lastIndexOf("}");
92
139
  if (firstObj !== -1 && lastObj > firstObj)
@@ -12,6 +12,7 @@
12
12
  */
13
13
 
14
14
  import { extractToolNames } from "./skill-synthesizer.js";
15
+ import { firstBalancedJson } from "../json-schema-output.js";
15
16
 
16
17
  // ── _deps for test injection ────────────────────────
17
18
  const _deps = {
@@ -268,9 +269,9 @@ export class ReflectionEngine {
268
269
  try {
269
270
  const messages = buildReflectionPrompt(reportData);
270
271
  const response = await this.llmChat(messages);
271
- const jsonMatch = response.match(/\{[\s\S]*\}/);
272
- if (!jsonMatch) return null;
273
- return JSON.parse(jsonMatch[0]);
272
+ const jsonText = firstBalancedJson(response, "{");
273
+ if (!jsonText) return null;
274
+ return JSON.parse(jsonText);
274
275
  } catch {
275
276
  return null;
276
277
  }
@@ -12,6 +12,7 @@
12
12
  */
13
13
 
14
14
  import fs from "fs";
15
+ import { firstBalancedJson } from "../json-schema-output.js";
15
16
  import path from "path";
16
17
 
17
18
  // ── _deps for test injection ────────────────────────────
@@ -374,9 +375,9 @@ export class SkillImprover {
374
375
  if (!this.llmChat) return null;
375
376
  try {
376
377
  const response = await this.llmChat(messages);
377
- const jsonMatch = response.match(/\{[\s\S]*\}/);
378
- if (!jsonMatch) return null;
379
- return JSON.parse(jsonMatch[0]);
378
+ const jsonText = firstBalancedJson(response, "{");
379
+ if (!jsonText) return null;
380
+ return JSON.parse(jsonText);
380
381
  } catch {
381
382
  return null;
382
383
  }
@@ -17,6 +17,7 @@
17
17
  */
18
18
 
19
19
  import fs from "fs";
20
+ import { firstBalancedJson } from "../json-schema-output.js";
20
21
  import path from "path";
21
22
 
22
23
  // ── _deps for test injection ────────────────────────────
@@ -258,9 +259,9 @@ export class SkillSynthesizer {
258
259
 
259
260
  try {
260
261
  // Try to parse JSON from response
261
- const jsonMatch = response.match(/\{[\s\S]*\}/);
262
- if (!jsonMatch) return null;
263
- return JSON.parse(jsonMatch[0]);
262
+ const jsonText = firstBalancedJson(response, "{");
263
+ if (!jsonText) return null;
264
+ return JSON.parse(jsonText);
264
265
  } catch {
265
266
  return null;
266
267
  }
@@ -15,5 +15,6 @@ export {
15
15
  inferTransport,
16
16
  isHttpTransport,
17
17
  isLikelyConnectionError,
18
+ isTransientMcpError,
18
19
  _deps,
19
20
  } from "../harness/mcp-client.js";
@@ -36,8 +36,13 @@ export const _deps = {
36
36
  createServer: (h) => http.createServer(h),
37
37
  openBrowser: defaultOpenBrowser,
38
38
  now: () => Date.now(),
39
+ // Backoff sleep seam (tests override with a no-op so the retry doesn't wait).
40
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
39
41
  };
40
42
 
43
+ /** Discovery/token requests retry ONCE after a transient error (CC 2.1.191). */
44
+ const MCP_OAUTH_RETRY_BACKOFF_MS = 300;
45
+
41
46
  const base64url = (buf) =>
42
47
  Buffer.from(buf)
43
48
  .toString("base64")
@@ -57,6 +62,43 @@ export function randomState(bytes = 16) {
57
62
  return base64url(_deps.randomBytes(bytes));
58
63
  }
59
64
 
65
+ /**
66
+ * Is `hostname` a loopback address? Plain http is tolerated only for these (a
67
+ * self-hosted MCP server on the dev box). Handles the bracketed IPv6 form that
68
+ * `new URL().hostname` yields (e.g. "[::1]") and the whole 127.0.0.0/8 range.
69
+ */
70
+ export function isLoopbackHost(hostname) {
71
+ const h = String(hostname || "")
72
+ .replace(/^\[|\]$/g, "")
73
+ .toLowerCase();
74
+ if (h === "localhost") return true;
75
+ if (h === "::1") return true;
76
+ return /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(h);
77
+ }
78
+
79
+ /**
80
+ * Require an OAuth endpoint to be HTTPS (or plain http on loopback for local
81
+ * dev). Authorization codes, PKCE verifiers, client secrets, and bearer/refresh
82
+ * tokens flow through these endpoints, so a discovered (or stored) endpoint that
83
+ * downgrades to cleartext http on a remote host must be refused rather than
84
+ * leaking secrets over the wire (RFC 6749 §3.1.2.1 / RFC 8252). Returns the
85
+ * endpoint unchanged when allowed; throws otherwise.
86
+ */
87
+ export function assertSecureEndpoint(endpoint, label = "endpoint") {
88
+ let u;
89
+ try {
90
+ u = new URL(endpoint);
91
+ } catch {
92
+ throw new Error(`OAuth ${label} is not a valid URL: ${endpoint}`);
93
+ }
94
+ if (u.protocol === "https:") return endpoint;
95
+ if (u.protocol === "http:" && isLoopbackHost(u.hostname)) return endpoint;
96
+ throw new Error(
97
+ `refusing to use insecure OAuth ${label} (${endpoint}); ` +
98
+ `HTTPS is required for non-loopback endpoints`,
99
+ );
100
+ }
101
+
60
102
  /** Minimal HTML-escape for values reflected into the callback page. */
61
103
  function escapeHtml(s) {
62
104
  return String(s).replace(
@@ -103,10 +145,29 @@ export function renderCallbackPage(error) {
103
145
  );
104
146
  }
105
147
 
106
- async function fetchJson(url, opts) {
107
- const res = await _deps.fetch(url, opts);
148
+ async function fetchJson(url, opts, attempt = 0) {
149
+ let res;
150
+ try {
151
+ res = await _deps.fetch(url, opts);
152
+ } catch (netErr) {
153
+ // fetch rejected = transient network error → retry once after a short wait
154
+ // (CC 2.1.191: "discovery and token requests now retry once after transient
155
+ // network errors").
156
+ if (attempt < 1) {
157
+ await _deps.sleep(MCP_OAUTH_RETRY_BACKOFF_MS);
158
+ return fetchJson(url, opts, attempt + 1);
159
+ }
160
+ throw netErr;
161
+ }
108
162
  if (!res || !res.ok) {
109
163
  const status = res ? res.status : "no-response";
164
+ // 5xx is transient → retry once. 4xx is permanent — and discovery probes
165
+ // 404s on purpose (it falls through to the next metadata URL), so retrying
166
+ // those would only slow the expected fall-through.
167
+ if (attempt < 1 && typeof status === "number" && status >= 500) {
168
+ await _deps.sleep(MCP_OAUTH_RETRY_BACKOFF_MS);
169
+ return fetchJson(url, opts, attempt + 1);
170
+ }
110
171
  let body = "";
111
172
  try {
112
173
  body = res ? await res.text() : "";
@@ -185,6 +246,7 @@ export async function registerClient(
185
246
  "server has no registration_endpoint and no --client-id was given",
186
247
  );
187
248
  }
249
+ assertSecureEndpoint(metadata.registration_endpoint, "registration_endpoint");
188
250
  const reg = await fetchJson(metadata.registration_endpoint, {
189
251
  method: "POST",
190
252
  headers: { "content-type": "application/json" },
@@ -206,6 +268,10 @@ export function buildAuthorizeUrl(
206
268
  metadata,
207
269
  { clientId, redirectUri, scope, codeChallenge, state, resource },
208
270
  ) {
271
+ assertSecureEndpoint(
272
+ metadata.authorization_endpoint,
273
+ "authorization_endpoint",
274
+ );
209
275
  const u = new URL(metadata.authorization_endpoint);
210
276
  u.searchParams.set("response_type", "code");
211
277
  u.searchParams.set("client_id", clientId);
@@ -237,6 +303,7 @@ export async function exchangeCodeForToken(
237
303
  });
238
304
  if (clientSecret) body.set("client_secret", clientSecret);
239
305
  if (resource) body.set("resource", resource);
306
+ assertSecureEndpoint(metadata.token_endpoint, "token_endpoint");
240
307
  const tok = await fetchJson(metadata.token_endpoint, {
241
308
  method: "POST",
242
309
  headers: { "content-type": "application/x-www-form-urlencoded" },
@@ -263,6 +330,7 @@ export async function refreshAccessToken(
263
330
  });
264
331
  if (clientSecret) body.set("client_secret", clientSecret);
265
332
  if (resource) body.set("resource", resource);
333
+ assertSecureEndpoint(metadata.token_endpoint, "token_endpoint");
266
334
  const tok = await fetchJson(metadata.token_endpoint, {
267
335
  method: "POST",
268
336
  headers: { "content-type": "application/x-www-form-urlencoded" },
@@ -310,7 +378,10 @@ export function getStoredToken(serverUrl) {
310
378
  // readable. writeFileSync's `mode` only applies when the file is created, so
311
379
  // also chmod (best-effort) to tighten a file an older version left at 0644.
312
380
  function _writeStoreSecure(file, store) {
313
- _deps.fs.mkdirSync(pathDefault.dirname(file), { recursive: true, mode: 0o700 });
381
+ _deps.fs.mkdirSync(pathDefault.dirname(file), {
382
+ recursive: true,
383
+ mode: 0o700,
384
+ });
314
385
  const data = JSON.stringify(store, null, 2) + "\n";
315
386
  // Atomic temp+rename: the store holds live OAuth tokens, so a crash (or a
316
387
  // concurrent `cc mcp login`) mid-write must not truncate it and lose auth for
@@ -326,7 +397,11 @@ function _writeStoreSecure(file, store) {
326
397
  _deps.fs.renameSync(tmp, file);
327
398
  } catch (err) {
328
399
  try {
329
- if (_deps.fs.existsSync && _deps.fs.existsSync(tmp) && _deps.fs.unlinkSync)
400
+ if (
401
+ _deps.fs.existsSync &&
402
+ _deps.fs.existsSync(tmp) &&
403
+ _deps.fs.unlinkSync
404
+ )
330
405
  _deps.fs.unlinkSync(tmp);
331
406
  } catch {
332
407
  /* best-effort temp cleanup */
@@ -341,26 +416,126 @@ function _writeStoreSecure(file, store) {
341
416
  }
342
417
  }
343
418
 
419
+ /** Bounded synchronous sleep (the store lock is held only for a few file ops). */
420
+ function _sleepSyncMs(ms) {
421
+ try {
422
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
423
+ } catch {
424
+ const end = Date.now() + ms;
425
+ while (Date.now() < end) {
426
+ /* spin — Atomics.wait unavailable (should not happen on modern Node) */
427
+ }
428
+ }
429
+ }
430
+
431
+ /**
432
+ * Run `fn` while holding a cross-process advisory lock on the token store.
433
+ *
434
+ * saveStoredToken / deleteStoredToken are read-modify-write (load → mutate →
435
+ * write). Two concurrent `cc mcp login` PROCESSES for different servers would
436
+ * each load the old store and the second writer would clobber the first's new
437
+ * entry (lost update) — the atomic temp+rename only prevents a torn file, not a
438
+ * lost update. An O_EXCL lockfile makes the whole RMW mutually exclusive across
439
+ * processes, so each writer re-reads the latest store inside the lock.
440
+ *
441
+ * Robustness: a stale lock (holder crashed) older than STALE_MS is stolen; if
442
+ * the lock can't be acquired within MAX_WAIT_MS we proceed anyway (degrading to
443
+ * the pre-lock last-writer-wins rather than failing the login). When the
444
+ * injected fs lacks the lock primitives (test fs / exotic fs) we run `fn`
445
+ * directly — single-process correctness is unchanged, only cross-process mutual
446
+ * exclusion is lost. Lock timing uses the wall clock (not the `now` test seam).
447
+ */
448
+ function withStoreLock(file, fn) {
449
+ const fs = _deps.fs;
450
+ if (
451
+ typeof fs.openSync !== "function" ||
452
+ typeof fs.closeSync !== "function" ||
453
+ typeof fs.unlinkSync !== "function"
454
+ ) {
455
+ return fn(); // no lock primitives → run unlocked (back-compat)
456
+ }
457
+ const STALE_MS = 10_000;
458
+ const MAX_WAIT_MS = 5_000;
459
+ const lockPath = `${file}.lock`;
460
+ try {
461
+ fs.mkdirSync(pathDefault.dirname(file), { recursive: true, mode: 0o700 });
462
+ } catch {
463
+ /* dir may already exist — openSync below reports any real problem */
464
+ }
465
+ const start = Date.now();
466
+ let fd = null;
467
+ for (;;) {
468
+ try {
469
+ fd = fs.openSync(lockPath, "wx"); // O_CREAT|O_EXCL — fails if held
470
+ break;
471
+ } catch (err) {
472
+ if (!err || err.code !== "EEXIST") {
473
+ // Unexpected (e.g. ENOENT/EACCES): don't fail the login over the lock —
474
+ // proceed unlocked, same as a missing-primitives fs.
475
+ return fn();
476
+ }
477
+ let stale = false;
478
+ try {
479
+ const st = fs.statSync(lockPath);
480
+ const mtime = st.mtimeMs != null ? st.mtimeMs : 0;
481
+ stale = Date.now() - mtime > STALE_MS;
482
+ } catch {
483
+ /* lock vanished between open and stat → retry immediately */
484
+ }
485
+ if (stale) {
486
+ try {
487
+ fs.unlinkSync(lockPath); // steal a lock from a crashed holder
488
+ } catch {
489
+ /* someone else stole it first — retry */
490
+ }
491
+ continue;
492
+ }
493
+ if (Date.now() - start > MAX_WAIT_MS) break; // give up waiting; proceed
494
+ _sleepSyncMs(50);
495
+ }
496
+ }
497
+ try {
498
+ return fn();
499
+ } finally {
500
+ if (fd !== null) {
501
+ try {
502
+ fs.closeSync(fd);
503
+ } catch {
504
+ /* ignore */
505
+ }
506
+ try {
507
+ fs.unlinkSync(lockPath);
508
+ } catch {
509
+ /* ignore — a stale-steal by another process may have removed it */
510
+ }
511
+ }
512
+ }
513
+ }
514
+
344
515
  export function saveStoredToken(serverUrl, record) {
345
516
  const file = tokenStorePath();
346
- const store = loadTokenStore();
347
- store[serverKey(serverUrl)] = { server: serverKey(serverUrl), ...record };
348
- _writeStoreSecure(file, store);
349
- return store[serverKey(serverUrl)];
517
+ return withStoreLock(file, () => {
518
+ const store = loadTokenStore(); // re-read inside the lock (latest state)
519
+ store[serverKey(serverUrl)] = { server: serverKey(serverUrl), ...record };
520
+ _writeStoreSecure(file, store);
521
+ return store[serverKey(serverUrl)];
522
+ });
350
523
  }
351
524
 
352
525
  export function deleteStoredToken(serverUrl) {
353
526
  const file = tokenStorePath();
354
- const store = loadTokenStore();
355
- const key = serverKey(serverUrl);
356
- if (!(key in store)) return false;
357
- delete store[key];
358
- try {
359
- _writeStoreSecure(file, store);
360
- } catch {
361
- /* best-effort */
362
- }
363
- return true;
527
+ return withStoreLock(file, () => {
528
+ const store = loadTokenStore(); // re-read inside the lock (latest state)
529
+ const key = serverKey(serverUrl);
530
+ if (!(key in store)) return false;
531
+ delete store[key];
532
+ try {
533
+ _writeStoreSecure(file, store);
534
+ } catch {
535
+ /* best-effort */
536
+ }
537
+ return true;
538
+ });
364
539
  }
365
540
 
366
541
  /** True when a record's access token is missing or within `skewMs` of expiry. */
@@ -409,12 +584,25 @@ function defaultOpenBrowser(url) {
409
584
  }
410
585
  }
411
586
 
412
- /** Wait for the OAuth redirect on a localhost callback server; resolve {code,state}. */
587
+ /**
588
+ * Wait for the OAuth redirect on a localhost callback server; resolve
589
+ * {code,state}.
590
+ *
591
+ * When `expectedState` is given, a SUCCESS callback (`code`) is only accepted if
592
+ * its `state` matches: the callback port is fixed and guessable, so a duplicate
593
+ * browser request, a probe, or a drive-by request from a malicious local page
594
+ * must not abort the real login or inject a forged code — those are answered
595
+ * politely but the server keeps waiting for the genuine redirect (until the
596
+ * backstop timeout). A provider `error` is always terminal (so denying consent
597
+ * fails fast even if the provider omits state on error). With no `expectedState`
598
+ * the legacy contract is preserved (first `/callback` hit is terminal).
599
+ */
413
600
  export function waitForCallback({
414
601
  port,
415
602
  host = "127.0.0.1",
416
603
  path = "/callback",
417
604
  timeout = 300_000,
605
+ expectedState = null,
418
606
  }) {
419
607
  return new Promise((resolve, reject) => {
420
608
  const server = _deps.createServer((req, res) => {
@@ -434,13 +622,41 @@ export function waitForCallback({
434
622
  const code = u.searchParams.get("code");
435
623
  const state = u.searchParams.get("state");
436
624
  const error = u.searchParams.get("error");
437
- res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
438
- res.end(renderCallbackPage(error));
625
+
626
+ // Provider error (e.g. user denied) — always terminal so we fail fast.
627
+ if (error) {
628
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
629
+ res.end(renderCallbackPage(error));
630
+ server.close();
631
+ clearTimeout(timer);
632
+ reject(new Error(`authorization error: ${error}`));
633
+ return;
634
+ }
635
+
636
+ const stateOk = expectedState == null || state === expectedState;
637
+ if (code && stateOk) {
638
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
639
+ res.end(renderCallbackPage(null));
640
+ server.close();
641
+ clearTimeout(timer);
642
+ resolve({ code, state });
643
+ return;
644
+ }
645
+
646
+ // A mismatched/forged/duplicate request to our fixed port must not abort
647
+ // the real login — answer it but keep listening for the genuine redirect.
648
+ if (expectedState != null) {
649
+ res.writeHead(400, { "content-type": "text/html; charset=utf-8" });
650
+ res.end(renderCallbackPage("state mismatch"));
651
+ return;
652
+ }
653
+
654
+ // Legacy contract (no expectedState): a /callback hit with no code ends it.
655
+ res.writeHead(400, { "content-type": "text/html; charset=utf-8" });
656
+ res.end(renderCallbackPage("missing code"));
439
657
  server.close();
440
658
  clearTimeout(timer);
441
- if (error) reject(new Error(`authorization error: ${error}`));
442
- else if (!code) reject(new Error("no authorization code in callback"));
443
- else resolve({ code, state });
659
+ reject(new Error("no authorization code in callback"));
444
660
  });
445
661
  const timer = setTimeout(() => {
446
662
  server.close();
@@ -509,6 +725,9 @@ export async function authorizeInteractive(serverUrl, opts = {}) {
509
725
  host,
510
726
  path: redirectPath,
511
727
  timeout,
728
+ // Ignore callbacks that don't carry the state we issued (forged / drive-by /
729
+ // duplicate hits to the fixed localhost port) instead of aborting the login.
730
+ expectedState: state,
512
731
  });
513
732
  const opened = _deps.openBrowser(authorizeUrl);
514
733
  writeOut(