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.
- package/package.json +1 -1
- package/src/assets/web-panel/.build-hash +1 -1
- package/src/assets/web-panel/assets/AIOps-BsDJlD_l.js +1 -0
- package/src/assets/web-panel/assets/{ActionButton-Bqvaz1Yj.js → ActionButton-COjZZKWG.js} +1 -1
- package/src/assets/web-panel/assets/Analytics-4ZDZE7lc.js +3 -0
- package/src/assets/web-panel/assets/{AppLayout-cs0TRZya.js → AppLayout-CYH5k2Rp.js} +5 -5
- package/src/assets/web-panel/assets/Audit-BKlFbLdl.js +1 -0
- package/src/assets/web-panel/assets/Backup-CiX61Qc2.js +1 -0
- package/src/assets/web-panel/assets/{BaseInput-4qhQiJfI.js → BaseInput-CDxIQokd.js} +1 -1
- package/src/assets/web-panel/assets/{Chat-DUFX3Mv4.js → Chat-CaKgdRab.js} +6 -6
- package/src/assets/web-panel/assets/ChatBubbleRenderer-DgvpwU5o.js +1 -0
- package/src/assets/web-panel/assets/{Checkbox-C4eyD_nB.js → Checkbox-CjiWfu4s.js} +1 -1
- package/src/assets/web-panel/assets/Codegen-tKWGzajw.js +1 -0
- package/src/assets/web-panel/assets/{Col-3eHStfvJ.js → Col-ZKWREyNs.js} +1 -1
- package/src/assets/web-panel/assets/{Community-DFbKjEgh.js → Community-CmLuPBUU.js} +1 -1
- package/src/assets/web-panel/assets/{Compact-D5str1h-.js → Compact-DqknM98n.js} +1 -1
- package/src/assets/web-panel/assets/Compliance-PZw0aBLI.js +1 -0
- package/src/assets/web-panel/assets/{Cowork-Cdliboj6.js → Cowork-Dp29kHsC.js} +2 -2
- package/src/assets/web-panel/assets/{Cron-Bfmwl6iZ.js → Cron-zmEJAetz.js} +2 -2
- package/src/assets/web-panel/assets/Crosschain-Sb-uM7Ty.js +1 -0
- package/src/assets/web-panel/assets/{DID-xqi72kjo.js → DID-D_UFNzJ5.js} +2 -2
- package/src/assets/web-panel/assets/{Dashboard-4l68QdSQ.js → Dashboard-Btag698v.js} +2 -2
- package/src/assets/web-panel/assets/{Dropdown-C2wsZOdn.js → Dropdown-CH7l3KCs.js} +1 -1
- package/src/assets/web-panel/assets/EmailListRenderer-Bud2M3XX.js +1 -0
- package/src/assets/web-panel/assets/{FamilyGuardDashboard-CHK5rapA.js → FamilyGuardDashboard-Bd2_8uME.js} +1 -1
- package/src/assets/web-panel/assets/Federation-1jhstF5P.js +1 -0
- package/src/assets/web-panel/assets/{FormItemContext-lLOEHvAb.js → FormItemContext-CpSH464Y.js} +1 -1
- package/src/assets/web-panel/assets/{GenericCardRenderer-CaDh2XiY.js → GenericCardRenderer-CANo8O-y.js} +1 -1
- package/src/assets/web-panel/assets/{Git-B9SmGPMU.js → Git-hvuZVoD3.js} +2 -2
- package/src/assets/web-panel/assets/Governance-BQrWksKt.js +1 -0
- package/src/assets/web-panel/assets/Inference-jEBTp12v.js +1 -0
- package/src/assets/web-panel/assets/KnowledgeGraph-tDiV2taf.js +1 -0
- package/src/assets/web-panel/assets/Logs-DDkTnedu.js +2 -0
- package/src/assets/web-panel/assets/Marketplace-9Yi32avt.js +1 -0
- package/src/assets/web-panel/assets/{McpTools-Dxlypa0R.js → McpTools-Qx78XyOT.js} +5 -5
- package/src/assets/web-panel/assets/{Memory-BxBHR7eW.js → Memory-CXYDO1oT.js} +2 -2
- package/src/assets/web-panel/assets/MobileBridge-MfUfkHA0.js +1 -0
- package/src/assets/web-panel/assets/{MobileProjects-59gyfK6T.js → MobileProjects-DEDbWNIk.js} +1 -1
- package/src/assets/web-panel/assets/{Mtc-2YBBawFi.js → Mtc-Dwa_vlR8.js} +2 -2
- package/src/assets/web-panel/assets/{MtcAudit-DczAeHfM.js → MtcAudit-DAVkxhJv.js} +2 -2
- package/src/assets/web-panel/assets/{Multisig-CPxyt7a-.js → Multisig-C3upmgPN.js} +3 -3
- package/src/assets/web-panel/assets/NLProgramming-DO3CZ0_z.js +1 -0
- package/src/assets/web-panel/assets/{Notes-tHRMLOQ3.js → Notes-B1Oy3FbC.js} +4 -4
- package/src/assets/web-panel/assets/{NotificationSettings-DxIYUWPW.js → NotificationSettings-Bs4-Fc6b.js} +1 -1
- package/src/assets/web-panel/assets/OrderTableRenderer-CEy1pIeq.js +1 -0
- package/src/assets/web-panel/assets/Organization-D4cJ1UNo.js +4 -0
- package/src/assets/web-panel/assets/{Overflow-DrdVw_fC.js → Overflow-fdzvlF7x.js} +1 -1
- package/src/assets/web-panel/assets/{P2P-CMBYxejX.js → P2P-Y13PwXBA.js} +2 -2
- package/src/assets/web-panel/assets/PdhVaultBrowser-Ck1zCLe6.js +7 -0
- package/src/assets/web-panel/assets/Permissions-CvEXP6LC.js +4 -0
- package/src/assets/web-panel/assets/{PersonalDataHub-nuqNTK2w.js → PersonalDataHub-JeP7IWMW.js} +3 -3
- package/src/assets/web-panel/assets/Pipeline-BZ7wRzG1.js +1 -0
- package/src/assets/web-panel/assets/Privacy-BJ6qj9Hv.js +1 -0
- package/src/assets/web-panel/assets/{ProjectInit-hCWXNnGJ.js → ProjectInit-DeWd9UcS.js} +2 -2
- package/src/assets/web-panel/assets/ProjectSettings-DUpVuU9F.js +2 -0
- package/src/assets/web-panel/assets/{Projects-C6l6z4Mo.js → Projects-y1WbI337.js} +1 -1
- package/src/assets/web-panel/assets/{Providers-kEifEWLb.js → Providers-DECW00ek.js} +1 -1
- package/src/assets/web-panel/assets/{QuickAsk-B6oLUYKR.js → QuickAsk-Dl-pK9Io.js} +1 -1
- package/src/assets/web-panel/assets/Recommend-Bju04wTd.js +1 -0
- package/src/assets/web-panel/assets/Reputation-BUPa3nEk.js +1 -0
- package/src/assets/web-panel/assets/{Row-W6LSmdzh.js → Row-WYqqaq_5.js} +1 -1
- package/src/assets/web-panel/assets/{RssFeed-DJi97lWh.js → RssFeed-iyQT5q9l.js} +3 -3
- package/src/assets/web-panel/assets/Search-CrfwJF0R.js +1 -0
- package/src/assets/web-panel/assets/{Security-BtawKhmm.js → Security-c4Jxf5Ih.js} +4 -4
- package/src/assets/web-panel/assets/{Services-BdCe11of.js → Services-CRuisolj.js} +2 -2
- package/src/assets/web-panel/assets/{Skeleton-C_wcDxpN.js → Skeleton-CCnzJkb-.js} +1 -1
- package/src/assets/web-panel/assets/{Skills-n2GgEfQ1.js → Skills-CZURCwZg.js} +1 -1
- package/src/assets/web-panel/assets/Sla-CrMzaEi2.js +1 -0
- package/src/assets/web-panel/assets/SpeechSettings-jTDOM5eY.js +1 -0
- package/src/assets/web-panel/assets/{SyncSettings-BBbldyeR.js → SyncSettings-CJWVtd87.js} +2 -2
- package/src/assets/web-panel/assets/{Tasks-DMkBOCW-.js → Tasks-61lrQ_lS.js} +1 -1
- package/src/assets/web-panel/assets/Templates-Cg-iF2g1.js +1 -0
- package/src/assets/web-panel/assets/Tenant-DrDv1FFc.js +1 -0
- package/src/assets/web-panel/assets/Terminal-CP15hcNS.js +3 -0
- package/src/assets/web-panel/assets/{TimelineRenderer-Drf_B0al.js → TimelineRenderer-Sl5uXrkN.js} +1 -1
- package/src/assets/web-panel/assets/Tokens-yRIZTVsR.js +1 -0
- package/src/assets/web-panel/assets/{Trigger-CNUB_qRQ.js → Trigger-BLCQEOF2.js} +1 -1
- package/src/assets/web-panel/assets/Trust-D5xq8z6s.js +1 -0
- package/src/assets/web-panel/assets/{UkeySign-7o1O51qV.js → UkeySign-Dwp0zXQ6.js} +1 -1
- package/src/assets/web-panel/assets/{VideoEditing-Ccb1cTxo.js → VideoEditing-CIHA55Sx.js} +1 -1
- package/src/assets/web-panel/assets/Wallet-Hjajvnto.js +4 -0
- package/src/assets/web-panel/assets/WebAuthn-DqSURUvI.js +5 -0
- package/src/assets/web-panel/assets/{WorkflowEditor-BcFvWc2d.js → WorkflowEditor-B5bHRwUG.js} +1 -1
- package/src/assets/web-panel/assets/{chat-CPvCSHY4.js → chat-DsheLKkG.js} +1 -1
- package/src/assets/web-panel/assets/{colors-Ca7MoLtb.js → colors-CST2UkgL.js} +1 -1
- package/src/assets/web-panel/assets/community-parser-Cuyslaku.js +3 -0
- package/src/assets/web-panel/assets/{compact-item-B9AUAVCQ.js → compact-item-yqEoy1L7.js} +1 -1
- package/src/assets/web-panel/assets/{createContext-CnMJHqHw.js → createContext-Jwp78HFY.js} +1 -1
- package/src/assets/web-panel/assets/devWarning-C8to6gQk.js +1 -0
- package/src/assets/web-panel/assets/{hasIn-Bevd8pXd.js → hasIn-CVS5mFEP.js} +1 -1
- package/src/assets/web-panel/assets/{index-gR1xGRvy.js → index-AnW25bEq.js} +1 -1
- package/src/assets/web-panel/assets/{index-B3zlqc60.js → index-BCHAUdel.js} +1 -1
- package/src/assets/web-panel/assets/{index-BzmhHe0m.js → index-BLTUVd0V.js} +1 -1
- package/src/assets/web-panel/assets/{index-BM1tI3g5.js → index-BeblNJDH.js} +1 -1
- package/src/assets/web-panel/assets/{index-CAKYScOz.js → index-BuI27WSx.js} +1 -1
- package/src/assets/web-panel/assets/{index-PidVLx0u.js → index-BzetOasL.js} +1 -1
- package/src/assets/web-panel/assets/{index-Dnk8TOm3.js → index-C0FZScMe.js} +1 -1
- package/src/assets/web-panel/assets/{index-CvhLkwFR.js → index-C130LLPq.js} +1 -1
- package/src/assets/web-panel/assets/{index-0DtNS6oi.js → index-C8rq85gu.js} +1 -1
- package/src/assets/web-panel/assets/{index-BUlsrbu2.js → index-CDAPnZUs.js} +1 -1
- package/src/assets/web-panel/assets/{index-HdGuA1pS.js → index-CIE2n8k_.js} +1 -1
- package/src/assets/web-panel/assets/{index-ecThTNhE.js → index-CRBbVS43.js} +1 -1
- package/src/assets/web-panel/assets/{index-DW3rTBy_.js → index-CSBPc-sI.js} +1 -1
- package/src/assets/web-panel/assets/{index-C5i4gKvN.js → index-CTd3lDxp.js} +1 -1
- package/src/assets/web-panel/assets/index-CUWj5L2s.js +1 -0
- package/src/assets/web-panel/assets/{index-B619_m_T.js → index-ChRL6rgA.js} +1 -1
- package/src/assets/web-panel/assets/{index-tUD_CWx-.js → index-CjfN2CpJ.js} +1 -1
- package/src/assets/web-panel/assets/{index-BcRDjaEi.js → index-Cjig5i3a.js} +3 -3
- package/src/assets/web-panel/assets/{index-Ct3wP67S.js → index-Crk4KWLW.js} +1 -1
- package/src/assets/web-panel/assets/{index-p1rI3d66.js → index-CrlRa054.js} +1 -1
- package/src/assets/web-panel/assets/{index-DPk2HXTA.js → index-CtyEOGuj.js} +1 -1
- package/src/assets/web-panel/assets/{index-aB7GLimB.js → index-Cu6Ql64n.js} +1 -1
- package/src/assets/web-panel/assets/{index-BFkrnvs_.js → index-Cx_t5rWw.js} +1 -1
- package/src/assets/web-panel/assets/{index-ienIjr7_.js → index-Cy0dNzkp.js} +1 -1
- package/src/assets/web-panel/assets/{index-DAjYLhik.js → index-CzsKK0tQ.js} +1 -1
- package/src/assets/web-panel/assets/{index-CGEc6vlv.js → index-DDAigsel.js} +1 -1
- package/src/assets/web-panel/assets/index-DZfnYE6e.js +1 -0
- package/src/assets/web-panel/assets/{index-C3biz3RB.js → index-D_1b7uh6.js} +1 -1
- package/src/assets/web-panel/assets/{index-D9VR1tES.js → index-D_e9gwfn.js} +1 -1
- package/src/assets/web-panel/assets/{index-BGBbFzIJ.js → index-DbxAlpPC.js} +1 -1
- package/src/assets/web-panel/assets/{index-CVxhWXMd.js → index-De600Jjm.js} +1 -1
- package/src/assets/web-panel/assets/{index-2KMpdEzs.js → index-Dgyn8-wN.js} +1 -1
- package/src/assets/web-panel/assets/{index-CQKVKyjf.js → index-DiK4vSZa.js} +1 -1
- package/src/assets/web-panel/assets/{index-CtIv89mI.js → index-DpYn5v2k.js} +1 -1
- package/src/assets/web-panel/assets/{index-DTxmgfz2.js → index-DxaU_lza.js} +1 -1
- package/src/assets/web-panel/assets/{index-Cd62bwRd.js → index-Dxljh9Fu.js} +1 -1
- package/src/assets/web-panel/assets/{index-kUeSjyHt.js → index-R-VGFpi6.js} +1 -1
- package/src/assets/web-panel/assets/{index-BwDJvHfR.js → index-SjxCCf5h.js} +1 -1
- package/src/assets/web-panel/assets/{index-BV1U7tR6.js → index-diWkx2Wq.js} +1 -1
- package/src/assets/web-panel/assets/{initDefaultProps-dsKHYCPH.js → initDefaultProps-ClAjrsQ-.js} +1 -1
- package/src/assets/web-panel/assets/{motion-DdPCK2zh.js → motion-BalXv_60.js} +1 -1
- package/src/assets/web-panel/assets/{move-Bi0bmOjg.js → move-DMelzIW-.js} +1 -1
- package/src/assets/web-panel/assets/mtc-parser-CEQffxds.js +1 -0
- package/src/assets/web-panel/assets/{omit-DCrBiySU.js → omit-oxecfU3b.js} +1 -1
- package/src/assets/web-panel/assets/{pickAttrs-CcJuqfya.js → pickAttrs-DJ5F5M6x.js} +1 -1
- package/src/assets/web-panel/assets/{placementArrow-DFGuzth-.js → placementArrow-DdbKsant.js} +1 -1
- package/src/assets/web-panel/assets/{responsiveObserve-B_WDlgvI.js → responsiveObserve-BUuM5cmS.js} +1 -1
- package/src/assets/web-panel/assets/{slide-BSFabVgO.js → slide-DSV0ccvR.js} +1 -1
- package/src/assets/web-panel/assets/{statusUtils-CebvsGH8.js → statusUtils-B-6oLAXf.js} +1 -1
- package/src/assets/web-panel/assets/{styleChecker-CMuE7NWy.js → styleChecker-DdCAHtsZ.js} +1 -1
- package/src/assets/web-panel/assets/{useFlexGapSupport-RWGVgZNu.js → useFlexGapSupport-ZORkcoZd.js} +1 -1
- package/src/assets/web-panel/assets/{useFs-srxzzfG_.js → useFs-BFp46LoP.js} +1 -1
- package/src/assets/web-panel/assets/{usePersonalDataHub-B5OwxZuK.js → usePersonalDataHub-H7rxgbdW.js} +1 -1
- package/src/assets/web-panel/assets/{vnode-CXOEebAH.js → vnode-eIq4Tk0J.js} +1 -1
- package/src/assets/web-panel/assets/{zoom-DKNwqG2v.js → zoom-CTNrv8-H.js} +1 -1
- package/src/assets/web-panel/index.html +1 -1
- package/src/commands/ask.js +24 -1
- package/src/commands/init.js +5 -4
- package/src/commands/instinct.js +18 -5
- package/src/commands/mcp.js +30 -3
- package/src/commands/memory.js +18 -5
- package/src/gateways/http/envelope-http-server.js +17 -2
- package/src/gateways/terminal/PtyManager.js +15 -2
- package/src/gateways/ws/ws-server.js +20 -0
- package/src/gateways/ws/ws-session-gateway.js +16 -0
- package/src/harness/mcp-client.js +124 -6
- package/src/harness/worktree-isolator.js +20 -9
- package/src/lib/agent-core.js +3 -0
- package/src/lib/autonomous-agent.js +7 -6
- package/src/lib/chat-intent-service.js +4 -2
- package/src/lib/checkpoint-store.js +23 -0
- package/src/lib/credential-guard.js +291 -0
- package/src/lib/ensure-utf8.js +6 -1
- package/src/lib/git-integration.js +57 -1
- package/src/lib/hook-manager.js +15 -6
- package/src/lib/hook-runner.cjs +11 -1
- package/src/lib/interactive-planner.js +4 -3
- package/src/lib/json-schema-output.js +47 -0
- package/src/lib/learning/reflection-engine.js +4 -3
- package/src/lib/learning/skill-improver.js +4 -3
- package/src/lib/learning/skill-synthesizer.js +4 -3
- package/src/lib/mcp-client.js +1 -0
- package/src/lib/mcp-oauth.js +243 -24
- package/src/lib/mcp-serve.js +56 -6
- package/src/lib/orchestrator.js +4 -2
- package/src/lib/process-manager.js +31 -3
- package/src/lib/repl-completer.js +29 -0
- package/src/lib/repl-rewind.js +58 -0
- package/src/lib/repl-vim.js +13 -1
- package/src/lib/settings-hooks.cjs +12 -3
- package/src/lib/slot-filler.js +4 -3
- package/src/lib/sub-agent-context.js +6 -0
- package/src/lib/web-fetch.js +54 -3
- package/src/lib/workflow-engine.js +35 -11
- package/src/repl/agent-repl.js +52 -2
- package/src/runtime/agent-core.js +324 -26
- package/src/runtime/headless-stream.js +124 -17
- package/src/assets/web-panel/assets/AIOps-BmS46GrQ.js +0 -1
- package/src/assets/web-panel/assets/Analytics-BhCnMbKz.js +0 -3
- package/src/assets/web-panel/assets/Audit-AwLNQzvX.js +0 -1
- package/src/assets/web-panel/assets/Backup-BOglo5g6.js +0 -1
- package/src/assets/web-panel/assets/ChatBubbleRenderer-LWgyWUF_.js +0 -1
- package/src/assets/web-panel/assets/Codegen-B9SRqAqh.js +0 -1
- package/src/assets/web-panel/assets/Compliance-DDYPQDiG.js +0 -1
- package/src/assets/web-panel/assets/Crosschain-Bfo6RvSg.js +0 -1
- package/src/assets/web-panel/assets/EmailListRenderer-CWh_fKmI.js +0 -1
- package/src/assets/web-panel/assets/Federation-JpiE2iKR.js +0 -1
- package/src/assets/web-panel/assets/Governance-B3ODoSO2.js +0 -1
- package/src/assets/web-panel/assets/Inference-C_jP_nlz.js +0 -1
- package/src/assets/web-panel/assets/KnowledgeGraph-zYwypnwu.js +0 -1
- package/src/assets/web-panel/assets/Logs-D6owai5o.js +0 -2
- package/src/assets/web-panel/assets/Marketplace-umRB80w4.js +0 -1
- package/src/assets/web-panel/assets/MobileBridge-Car9kLtE.js +0 -3
- package/src/assets/web-panel/assets/NLProgramming-DvKJ8imu.js +0 -1
- package/src/assets/web-panel/assets/OrderTableRenderer-BMpVfQkg.js +0 -1
- package/src/assets/web-panel/assets/Organization-BBsie-kN.js +0 -4
- package/src/assets/web-panel/assets/PdhVaultBrowser-BBHzSaeq.js +0 -7
- package/src/assets/web-panel/assets/Permissions-W4kD06Cj.js +0 -4
- package/src/assets/web-panel/assets/Pipeline-DzCX3yHy.js +0 -1
- package/src/assets/web-panel/assets/Privacy-9ENAJQOC.js +0 -1
- package/src/assets/web-panel/assets/ProjectSettings--BzvEaxE.js +0 -2
- package/src/assets/web-panel/assets/Recommend-BIEMGN0q.js +0 -1
- package/src/assets/web-panel/assets/Reputation-D-9AEaRr.js +0 -1
- package/src/assets/web-panel/assets/Search-DOEB2utf.js +0 -1
- package/src/assets/web-panel/assets/Sla-D5WfrvLP.js +0 -1
- package/src/assets/web-panel/assets/SpeechSettings-D6qCWpan.js +0 -1
- package/src/assets/web-panel/assets/Templates-DbLImR1I.js +0 -1
- package/src/assets/web-panel/assets/Tenant-B0qM4w6Z.js +0 -1
- package/src/assets/web-panel/assets/Terminal-BjKUkiR1.js +0 -3
- package/src/assets/web-panel/assets/Tokens-DgCx2Bfa.js +0 -1
- package/src/assets/web-panel/assets/Trust-JQUTjXXI.js +0 -1
- package/src/assets/web-panel/assets/Wallet-EMf9MlLK.js +0 -4
- package/src/assets/web-panel/assets/WebAuthn-DOYoaCmi.js +0 -5
- package/src/assets/web-panel/assets/community-parser-CO25nZFt.js +0 -3
- package/src/assets/web-panel/assets/devWarning-vFgm_Ssk.js +0 -1
- package/src/assets/web-panel/assets/index-BGUjPh7h.js +0 -1
- package/src/assets/web-panel/assets/index-Cx-wP5XE.js +0 -1
- 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
|
*/
|
package/src/lib/hook-manager.js
CHANGED
|
@@ -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"
|
|
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
|
-
//
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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 ?)
|
package/src/lib/hook-runner.cjs
CHANGED
|
@@ -107,7 +107,17 @@ function tryParseDecision(stdout) {
|
|
|
107
107
|
* nonBlockingError?:boolean, error?:string }}
|
|
108
108
|
*/
|
|
109
109
|
function runCommandHook(command, input = {}, opts = {}) {
|
|
110
|
-
const {
|
|
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
|
|
349
|
-
if (!
|
|
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(
|
|
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
|
|
272
|
-
if (!
|
|
273
|
-
return JSON.parse(
|
|
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
|
|
378
|
-
if (!
|
|
379
|
-
return JSON.parse(
|
|
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
|
|
262
|
-
if (!
|
|
263
|
-
return JSON.parse(
|
|
262
|
+
const jsonText = firstBalancedJson(response, "{");
|
|
263
|
+
if (!jsonText) return null;
|
|
264
|
+
return JSON.parse(jsonText);
|
|
264
265
|
} catch {
|
|
265
266
|
return null;
|
|
266
267
|
}
|
package/src/lib/mcp-client.js
CHANGED
package/src/lib/mcp-oauth.js
CHANGED
|
@@ -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
|
-
|
|
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), {
|
|
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 (
|
|
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
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
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
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
438
|
-
|
|
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
|
-
|
|
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(
|