chainlesschain 0.162.154 → 0.162.155
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/README.md +1 -1
- package/package.json +1 -1
- package/src/assets/web-panel/.build-hash +1 -1
- package/src/assets/web-panel/assets/{AIOps-CIpoXyVI.js → AIOps-Cth_zZeA.js} +1 -1
- package/src/assets/web-panel/assets/{ActionButton-Ch_QXIPk.js → ActionButton-zfwA3Oxn.js} +1 -1
- package/src/assets/web-panel/assets/{Analytics-CvaBYShv.js → Analytics-FeKc4aU2.js} +2 -2
- package/src/assets/web-panel/assets/AppLayout-C6WIrDZk.js +9 -0
- package/src/assets/web-panel/assets/{Audit-Bng8eHCW.js → Audit-C2gRq5Qi.js} +1 -1
- package/src/assets/web-panel/assets/BackgroundAgents-Dq2340Kl.css +1 -0
- package/src/assets/web-panel/assets/BackgroundAgents-TeTIFiW-.js +1 -0
- package/src/assets/web-panel/assets/{Backup-DvBs8-lm.js → Backup-CLJk3k6i.js} +1 -1
- package/src/assets/web-panel/assets/{BaseInput-DKwajm0g.js → BaseInput-5ms537NY.js} +1 -1
- package/src/assets/web-panel/assets/{Chat-BoGpZ3uO.js → Chat-4Nr5U9PJ.js} +5 -5
- package/src/assets/web-panel/assets/ChatBubbleRenderer-BFEnA8LN.js +1 -0
- package/src/assets/web-panel/assets/{Checkbox-BMJEmxFQ.js → Checkbox-B2Bca5ev.js} +1 -1
- package/src/assets/web-panel/assets/{Codegen-Bsoag5CV.js → Codegen-D6Uysdbv.js} +1 -1
- package/src/assets/web-panel/assets/{Col-CDjH9-sp.js → Col-B3XQ8xc7.js} +1 -1
- package/src/assets/web-panel/assets/{Community-Cg2QHPQ6.js → Community-CqwKtoPz.js} +1 -1
- package/src/assets/web-panel/assets/{Compact-BU2BaqP-.js → Compact-BZZINTus.js} +1 -1
- package/src/assets/web-panel/assets/{Compliance-D6ZYMCqZ.js → Compliance-Dr3BXkWA.js} +1 -1
- package/src/assets/web-panel/assets/{Cowork-DaZ8VW-I.js → Cowork-rx_x9Wqc.js} +2 -2
- package/src/assets/web-panel/assets/{Cron-DQY1RH9v.js → Cron-BpZr7rHc.js} +2 -2
- package/src/assets/web-panel/assets/{Crosschain-BVR28vk9.js → Crosschain-CeV5EYVg.js} +1 -1
- package/src/assets/web-panel/assets/{DID-N44KvTP-.js → DID-fe2Qaowz.js} +2 -2
- package/src/assets/web-panel/assets/{Dashboard-Bjhlx6wS.js → Dashboard-Fh_EdWVa.js} +2 -2
- package/src/assets/web-panel/assets/{Dropdown-CpLYxpy1.js → Dropdown-N1PIwWYf.js} +1 -1
- package/src/assets/web-panel/assets/{EmailListRenderer-srEITSPI.js → EmailListRenderer-B6vK93Ed.js} +1 -1
- package/src/assets/web-panel/assets/{FamilyGuardDashboard-juOC_fX2.js → FamilyGuardDashboard-e3iUZhAg.js} +1 -1
- package/src/assets/web-panel/assets/{Federation-nRaQtaXW.js → Federation-uInc9YcN.js} +1 -1
- package/src/assets/web-panel/assets/{FormItemContext-DflhTj09.js → FormItemContext-DmQZSX0z.js} +1 -1
- package/src/assets/web-panel/assets/{GenericCardRenderer-DulI_3NI.js → GenericCardRenderer-CV_Z8OAo.js} +1 -1
- package/src/assets/web-panel/assets/{Git-BxOPefXn.js → Git-jXAhNN8d.js} +2 -2
- package/src/assets/web-panel/assets/{Governance-C6G_Wnwi.js → Governance-Blw1esBB.js} +1 -1
- package/src/assets/web-panel/assets/{Inference-BnFYa2BF.js → Inference-CoK8Ia0Z.js} +1 -1
- package/src/assets/web-panel/assets/{KnowledgeGraph-TqQLEabm.js → KnowledgeGraph-5xrglMTK.js} +1 -1
- package/src/assets/web-panel/assets/{Logs-BH4AVh7s.js → Logs-BGIG9798.js} +2 -2
- package/src/assets/web-panel/assets/{Marketplace-DoWLTaEE.js → Marketplace-DFqhJMuY.js} +1 -1
- package/src/assets/web-panel/assets/{McpTools-D6yC7OG_.js → McpTools-7U8tEhwK.js} +5 -5
- package/src/assets/web-panel/assets/{Memory-DLCeHj21.js → Memory-BrvboPLE.js} +2 -2
- package/src/assets/web-panel/assets/{MobileBridge-BZBE0GRl.js → MobileBridge-nzcd7d1H.js} +1 -1
- package/src/assets/web-panel/assets/{MobileProjects-DJA15G1J.js → MobileProjects-DETVNKa7.js} +1 -1
- package/src/assets/web-panel/assets/{Mtc-CyTKJDHn.js → Mtc-SZcCYec3.js} +4 -4
- package/src/assets/web-panel/assets/{MtcAudit-DZKjgEVc.js → MtcAudit-Cx_d3N17.js} +5 -5
- package/src/assets/web-panel/assets/{Multisig-2rMPNhSO.js → Multisig-DtcmSUo-.js} +3 -3
- package/src/assets/web-panel/assets/{NLProgramming-VeMk4Fx-.js → NLProgramming-Dgx2QHl-.js} +1 -1
- package/src/assets/web-panel/assets/{Notes-jr2GHMhh.js → Notes-oHTGEHoJ.js} +3 -3
- package/src/assets/web-panel/assets/{NotificationSettings-WN9VZyFb.js → NotificationSettings-qKD-IlOl.js} +1 -1
- package/src/assets/web-panel/assets/{OrderTableRenderer-CjNO-e9T.js → OrderTableRenderer-CiU4Sy1J.js} +1 -1
- package/src/assets/web-panel/assets/{Organization-DdjVHuzT.js → Organization-BBNXnltP.js} +4 -4
- package/src/assets/web-panel/assets/{Overflow-l8vBsGGq.js → Overflow-Df2fDbvT.js} +1 -1
- package/src/assets/web-panel/assets/{OverrideContext-BlgL9440.js → OverrideContext-C0w0P4A3.js} +1 -1
- package/src/assets/web-panel/assets/{P2P-CVQDHHwq.js → P2P-DUHj6zjO.js} +2 -2
- package/src/assets/web-panel/assets/{PdhVaultBrowser-CTsNh8qE.js → PdhVaultBrowser-B2nWgdoV.js} +4 -4
- package/src/assets/web-panel/assets/{Permissions-DLW87uyK.js → Permissions-CiW0afny.js} +3 -3
- package/src/assets/web-panel/assets/{PersonalDataHub-N8OvASTx.js → PersonalDataHub-cc1mHU_D.js} +2 -2
- package/src/assets/web-panel/assets/{Pipeline-BPZmdlz2.js → Pipeline-B93zTapU.js} +1 -1
- package/src/assets/web-panel/assets/{Privacy-MFZw_GNh.js → Privacy-Db1jXWfd.js} +1 -1
- package/src/assets/web-panel/assets/{ProjectInit-zW0pEsfU.js → ProjectInit-DFoZ1h9U.js} +2 -2
- package/src/assets/web-panel/assets/{ProjectSettings-CppVHr_o.js → ProjectSettings-BkkUnmYs.js} +2 -2
- package/src/assets/web-panel/assets/{Projects-BTglkJO2.js → Projects-Xa779XLC.js} +1 -1
- package/src/assets/web-panel/assets/{Providers-CPHZ6JKT.js → Providers-DOF01IEN.js} +1 -1
- package/src/assets/web-panel/assets/{QrScannerModal-DO4klNZ2.js → QrScannerModal-TohRsKd0.js} +1 -1
- package/src/assets/web-panel/assets/{QuickAsk-B37TTqUa.js → QuickAsk-DjLcr1ZU.js} +1 -1
- package/src/assets/web-panel/assets/{Recommend-n7KIfK7y.js → Recommend--Ism6bMx.js} +1 -1
- package/src/assets/web-panel/assets/{RemoteSession-SjVoS9mm.js → RemoteSession-B8CL3YHM.js} +2 -2
- package/src/assets/web-panel/assets/{Reputation-Dt6RTeHi.js → Reputation-CaRxB3Ep.js} +1 -1
- package/src/assets/web-panel/assets/{Row-Dty6jz5V.js → Row-BioxZBBH.js} +1 -1
- package/src/assets/web-panel/assets/{RssFeed-DDLhqw5j.js → RssFeed-B2LipwH3.js} +3 -3
- package/src/assets/web-panel/assets/{Search-DrYbXi-W.js → Search-BpjLBV8u.js} +1 -1
- package/src/assets/web-panel/assets/{Security-Fc9Z6fbB.js → Security-TibHuJWP.js} +3 -3
- package/src/assets/web-panel/assets/{Services-BAhPgjSM.js → Services-DtkuWseW.js} +2 -2
- package/src/assets/web-panel/assets/{Skeleton-8JF7EPfE.js → Skeleton-BIcPQae6.js} +1 -1
- package/src/assets/web-panel/assets/Skills-DoTWIQOX.js +1 -0
- package/src/assets/web-panel/assets/{Sla-CSvjcATc.js → Sla-uUSU185m.js} +1 -1
- package/src/assets/web-panel/assets/{SpeechSettings-Demx7Gnx.js → SpeechSettings-B7Bgc9mk.js} +1 -1
- package/src/assets/web-panel/assets/{SyncSettings-CKLnRWJr.js → SyncSettings-K-XrJ2s9.js} +2 -2
- package/src/assets/web-panel/assets/{Tasks-mhITRxjv.js → Tasks-B3hEXNcI.js} +1 -1
- package/src/assets/web-panel/assets/{Templates-AjQI5rxD.js → Templates-CaFnyGDK.js} +1 -1
- package/src/assets/web-panel/assets/{Tenant-dbtEh1yR.js → Tenant-C2X0PtEC.js} +1 -1
- package/src/assets/web-panel/assets/{Terminal-DX-9jjfk.js → Terminal-CKU5yVsT.js} +2 -2
- package/src/assets/web-panel/assets/{TimelineRenderer-C11EDZRt.js → TimelineRenderer-8trZeYDC.js} +1 -1
- package/src/assets/web-panel/assets/{Tokens-GiA9fiZn.js → Tokens-BUEEe9US.js} +1 -1
- package/src/assets/web-panel/assets/{Trigger-BPcnclJn.js → Trigger-COU97I7Y.js} +1 -1
- package/src/assets/web-panel/assets/{Trust-C-DVY8nq.js → Trust-DNWQChaP.js} +1 -1
- package/src/assets/web-panel/assets/{UkeySign-DxodSwME.js → UkeySign-DWX_VtCp.js} +1 -1
- package/src/assets/web-panel/assets/{VideoEditing-D_WV0O8O.js → VideoEditing-D9eIDhsp.js} +1 -1
- package/src/assets/web-panel/assets/{Wallet-DSeNlVSU.js → Wallet-BOnCCgIL.js} +4 -4
- package/src/assets/web-panel/assets/{WebAuthn-Bwuuvpz1.js → WebAuthn-DN4dD-W8.js} +4 -4
- package/src/assets/web-panel/assets/{WorkflowEditor-8M96EU1N.js → WorkflowEditor-ClvDb_jX.js} +1 -1
- package/src/assets/web-panel/assets/{chat-D5S6SmLm.js → chat-DIxQ_Eeb.js} +1 -1
- package/src/assets/web-panel/assets/{collapseMotion-DVfhbsdP.js → collapseMotion-CqM1QOwM.js} +1 -1
- package/src/assets/web-panel/assets/{colors-5fbnGkAP.js → colors-BOX3WKcP.js} +1 -1
- package/src/assets/web-panel/assets/{compact-item-DaSuuMlW.js → compact-item-Bny82VDi.js} +1 -1
- package/src/assets/web-panel/assets/{createContext-BNOHcXAF.js → createContext-Cnoy3enS.js} +1 -1
- package/src/assets/web-panel/assets/devWarning-Dr9dzlQ5.js +1 -0
- package/src/assets/web-panel/assets/{echarts-CL0F2SnX.js → echarts-C61Vn3mP.js} +1 -1
- package/src/assets/web-panel/assets/{hasIn-DONwZUra.js → hasIn-BJ7fkx3F.js} +1 -1
- package/src/assets/web-panel/assets/{icons-BsDmGpcT.js → icons-DEmcnxBf.js} +1 -1
- package/src/assets/web-panel/assets/{index-B_yDD9Lm.js → index--CcGiwAI.js} +1 -1
- package/src/assets/web-panel/assets/{index-5_fj5gVd.js → index--Xb2lq6E.js} +1 -1
- package/src/assets/web-panel/assets/{index-uR5KTNZA.js → index-2N9Y1cLx.js} +1 -1
- package/src/assets/web-panel/assets/{index-MOgO5Yvx.js → index-7GLXvG8p.js} +1 -1
- package/src/assets/web-panel/assets/{index-CLXf-8ek.js → index-B1-h2QrC.js} +1 -1
- package/src/assets/web-panel/assets/{index-D7gs_3Ck.js → index-B6WoW8DJ.js} +1 -1
- package/src/assets/web-panel/assets/{index-CztESwB4.js → index-B7Fah_BX.js} +1 -1
- package/src/assets/web-panel/assets/index-B92M3t6B.js +1 -0
- package/src/assets/web-panel/assets/{index-BEO5f7Vf.js → index-BDkN6YOI.js} +1 -1
- package/src/assets/web-panel/assets/{index-_6S41ikV.js → index-BJ8-oLHI.js} +1 -1
- package/src/assets/web-panel/assets/{index-CrkCFohi.js → index-BM0mQhVv.js} +1 -1
- package/src/assets/web-panel/assets/{index-BrAJSu-v.js → index-BP7HFsAK.js} +1 -1
- package/src/assets/web-panel/assets/{index-BcqOK7Lv.js → index-BWPey0Uf.js} +1 -1
- package/src/assets/web-panel/assets/{index-BLzMuz38.js → index-BbZynivw.js} +1 -1
- package/src/assets/web-panel/assets/{index-DbdQBn4n.js → index-BcmXbIwv.js} +1 -1
- package/src/assets/web-panel/assets/{index-KwV9g_cf.js → index-Bk5yST66.js} +1 -1
- package/src/assets/web-panel/assets/{index-ClGqO-2X.js → index-Bvheiep2.js} +1 -1
- package/src/assets/web-panel/assets/{index-Dd7xwGcD.js → index-C--PPqwi.js} +1 -1
- package/src/assets/web-panel/assets/{index-DP2-3A_j.js → index-C1nHEOFE.js} +1 -1
- package/src/assets/web-panel/assets/{index-CAYmc5Nh.js → index-CEPTiBWy.js} +1 -1
- package/src/assets/web-panel/assets/{index-LRIt-AFl.js → index-CEq5Vh0i.js} +1 -1
- package/src/assets/web-panel/assets/{index-Dm5A-MN5.js → index-CN6emwbq.js} +1 -1
- package/src/assets/web-panel/assets/{index-Cf2OCa8q.js → index-CQ-xLCVb.js} +1 -1
- package/src/assets/web-panel/assets/{index-DHx2lhHW.js → index-CW6n-y1L.js} +1 -1
- package/src/assets/web-panel/assets/{index-DM3Ug61e.js → index-CZ6CHiCQ.js} +1 -1
- package/src/assets/web-panel/assets/{index-BJp4kRjw.js → index-CZCTan8Q.js} +1 -1
- package/src/assets/web-panel/assets/{index-PsMErAB1.js → index-CnjtAKdw.js} +1 -1
- package/src/assets/web-panel/assets/{index-CEzd5m5a.js → index-D2qNdrNW.js} +1 -1
- package/src/assets/web-panel/assets/{index-B4L3gsNA.js → index-D9QEc7Tq.js} +1 -1
- package/src/assets/web-panel/assets/{index-BcWMZqb4.js → index-DBhkJqVF.js} +1 -1
- package/src/assets/web-panel/assets/{index-CSfYJqXt.js → index-DSRIjAWO.js} +1 -1
- package/src/assets/web-panel/assets/{index-CmUOjyL6.js → index-DxX8hhmL.js} +1 -1
- package/src/assets/web-panel/assets/{index-RkInlwV0.js → index-Kio1ekYg.js} +1 -1
- package/src/assets/web-panel/assets/{index-CTZeOnzO.js → index-OaNFXV8N.js} +1 -1
- package/src/assets/web-panel/assets/{index-BK2N5yVW.js → index-Ty4w18mR.js} +4 -4
- package/src/assets/web-panel/assets/{index-Cn5LtyIH.js → index-ceH9DsAi.js} +1 -1
- package/src/assets/web-panel/assets/index-d2UcGpK-.js +1 -0
- package/src/assets/web-panel/assets/{index-xo2f4DZR.js → index-qOBKrw-g.js} +1 -1
- package/src/assets/web-panel/assets/{index-vYtprlNs.js → index-rn6xd12s.js} +1 -1
- package/src/assets/web-panel/assets/{initDefaultProps-CxwLynp9.js → initDefaultProps-BmxMBuUZ.js} +1 -1
- package/src/assets/web-panel/assets/{motion-XbPvGJbW.js → motion-DnXNwo2k.js} +1 -1
- package/src/assets/web-panel/assets/{move-D1u5pcgS.js → move-WnIJwmZN.js} +1 -1
- package/src/assets/web-panel/assets/{mtc-parser-BDxhTBMH.js → mtc-parser-DAto-c2w.js} +1 -1
- package/src/assets/web-panel/assets/{omit-EtIFGg5k.js → omit-BtvW9W0H.js} +1 -1
- package/src/assets/web-panel/assets/{pickAttrs-D33sWb0l.js → pickAttrs-DFqI9-_C.js} +1 -1
- package/src/assets/web-panel/assets/{placementArrow-BwFRzaGo.js → placementArrow-Dax6wOK3.js} +1 -1
- package/src/assets/web-panel/assets/{responsiveObserve-CBk2WqvQ.js → responsiveObserve-BUFWWC83.js} +1 -1
- package/src/assets/web-panel/assets/{slide-B52HtrKn.js → slide-DyARyuSz.js} +1 -1
- package/src/assets/web-panel/assets/{statusUtils-D4qxqWnH.js → statusUtils-BZSDugpi.js} +1 -1
- package/src/assets/web-panel/assets/{styleChecker-BxaCFlOL.js → styleChecker-B7o_K-4R.js} +1 -1
- package/src/assets/web-panel/assets/useFlexGapSupport-Dg7Bg8VG.js +1 -0
- package/src/assets/web-panel/assets/{useFs-C6VAU1_6.js → useFs-DQYXo5Rk.js} +1 -1
- package/src/assets/web-panel/assets/{useMergedState-Dc9sO2d7.js → useMergedState-DXbJa_zE.js} +1 -1
- package/src/assets/web-panel/assets/{usePersonalDataHub-rEG6oXG8.js → usePersonalDataHub-E8NBAnkq.js} +1 -1
- package/src/assets/web-panel/assets/{useRefs-CAeFRzPf.js → useRefs-BrCfMLMc.js} +1 -1
- package/src/assets/web-panel/assets/{useState-LwExUYDO.js → useState-CnxtbLoC.js} +1 -1
- package/src/assets/web-panel/assets/{vendor-BMxUs6UX.js → vendor-Bn1JzzAn.js} +1 -1
- package/src/assets/web-panel/assets/{vnode-DOTafkEi.js → vnode-DD71_w1b.js} +1 -1
- package/src/assets/web-panel/assets/{zoom-BuwwN5K2.js → zoom-zc9EMd3q.js} +1 -1
- package/src/assets/web-panel/index.html +3 -3
- package/src/command-manifest.json +36 -1
- package/src/commands/agent.js +23 -2
- package/src/commands/agents.js +62 -1
- package/src/commands/auto-mode.js +87 -0
- package/src/commands/background-session.js +499 -0
- package/src/commands/command.js +4 -1
- package/src/commands/permissions.js +49 -0
- package/src/commands/remote-control.js +407 -0
- package/src/data/changelog.json +7 -2
- package/src/gateways/ws/background-agent-protocol.js +337 -0
- package/src/gateways/ws/message-dispatcher.js +10 -0
- package/src/gateways/ws/remote-session-protocol.js +102 -1
- package/src/gateways/ws/ws-server.js +61 -0
- package/src/harness/mcp-client.js +8 -0
- package/src/index.js +10 -0
- package/src/lib/auto-mode-config.js +396 -0
- package/src/lib/background-agent-supervisor.js +202 -17
- package/src/lib/background-session-transport.js +315 -0
- package/src/lib/permission-denial-store.js +159 -0
- package/src/lib/remote-approval-bridge.js +360 -0
- package/src/lib/remote-control.js +270 -0
- package/src/lib/repl-denials.js +26 -0
- package/src/lib/shell-approval.js +4 -0
- package/src/lib/ws-rpc-client.js +150 -0
- package/src/repl/agent-repl.js +208 -16
- package/src/repl/permission-tier.js +31 -0
- package/src/runtime/agent-core.js +57 -0
- package/src/runtime/headless-runner.js +126 -8
- package/src/runtime/headless-stream.js +87 -1
- package/src/runtime/mcp-config.js +11 -0
- package/src/runtime/mcp-tool-search.js +0 -0
- package/src/runtime/policies/agent-policy.js +17 -0
- package/src/workers/background-agent-worker.js +218 -37
- package/src/assets/web-panel/assets/AppLayout-B6Q6olcr.js +0 -9
- package/src/assets/web-panel/assets/ChatBubbleRenderer-mDQl_PXO.js +0 -1
- package/src/assets/web-panel/assets/Skills-DL39dY0t.js +0 -1
- package/src/assets/web-panel/assets/devWarning-K_woVElh.js +0 -1
- package/src/assets/web-panel/assets/index-3ILvaBpo.js +0 -1
- package/src/assets/web-panel/assets/index-JpB-FoZq.js +0 -1
- package/src/assets/web-panel/assets/useFlexGapSupport-B1lWlqXN.js +0 -1
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RemoteApprovalBridge — routes interactive permission asks from a LOCAL
|
|
3
|
+
* agent process (headless run / REPL) to paired mobile/web devices
|
|
4
|
+
* (gap-analysis 第四阶段 #2).
|
|
5
|
+
*
|
|
6
|
+
* The bridge is the remote-session HOST for a client-hosted session: it
|
|
7
|
+
* connects to a cc WS server (self-hosted or `cc remote-control start`),
|
|
8
|
+
* registers a remote session under this process's agent session id, and then
|
|
9
|
+
*
|
|
10
|
+
* ask: publishes a `permission.request` runtime event (fan-out delivers
|
|
11
|
+
* to WS members, E2EE relay members, and triggers vendor push wake —
|
|
12
|
+
* the event type matches isApprovalRequestEvent).
|
|
13
|
+
* answer: paired devices publish `approval.resolve`; because the session is
|
|
14
|
+
* NOT server-hosted, the server forwards it back to this host as a
|
|
15
|
+
* `remote-session-control` frame (batch-18 protocol extension) and
|
|
16
|
+
* the bridge settles the pending gate decision.
|
|
17
|
+
* after: publishes `permission.resolved` so device UIs can clear the card.
|
|
18
|
+
*
|
|
19
|
+
* `makeConfirmer()` adapts this to the ApprovalGate confirmer contract
|
|
20
|
+
* (`async (ctx) => boolean`) with an optional local fallback racing the
|
|
21
|
+
* remote decision and a fail-closed timeout.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { randomBytes } from "crypto";
|
|
25
|
+
import { WsRpcClient } from "./ws-rpc-client.js";
|
|
26
|
+
import {
|
|
27
|
+
buildDirectPairingUri,
|
|
28
|
+
pickLanAddress,
|
|
29
|
+
renderQrCode,
|
|
30
|
+
resolveRemoteControlOptions,
|
|
31
|
+
} from "./remote-control.js";
|
|
32
|
+
|
|
33
|
+
const DEFAULT_DECISION_TIMEOUT_MS = 5 * 60 * 1000;
|
|
34
|
+
|
|
35
|
+
export class RemoteApprovalBridge {
|
|
36
|
+
constructor({
|
|
37
|
+
wsUrl,
|
|
38
|
+
token = null,
|
|
39
|
+
agentSessionId,
|
|
40
|
+
name = null,
|
|
41
|
+
scopes = ["observe", "approve"],
|
|
42
|
+
decisionTimeoutMs = DEFAULT_DECISION_TIMEOUT_MS,
|
|
43
|
+
createClient = null,
|
|
44
|
+
now = Date.now,
|
|
45
|
+
} = {}) {
|
|
46
|
+
if (!wsUrl) throw new Error("wsUrl is required");
|
|
47
|
+
if (!agentSessionId) throw new Error("agentSessionId is required");
|
|
48
|
+
this.wsUrl = wsUrl;
|
|
49
|
+
this.token = token;
|
|
50
|
+
this.agentSessionId = agentSessionId;
|
|
51
|
+
this.name = name || `approval-bridge:${agentSessionId}`;
|
|
52
|
+
this.scopes = scopes;
|
|
53
|
+
this.decisionTimeoutMs = decisionTimeoutMs;
|
|
54
|
+
this._createClient = createClient || ((url) => new WsRpcClient({ url }));
|
|
55
|
+
this._now = now;
|
|
56
|
+
this.client = null;
|
|
57
|
+
this.remoteSessionId = null;
|
|
58
|
+
this.pairing = null;
|
|
59
|
+
this._pending = new Map(); // requestId → { resolve, timer }
|
|
60
|
+
this._counter = 0;
|
|
61
|
+
this._closed = false;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Connect, register the client-hosted remote session, start listening. */
|
|
65
|
+
async start() {
|
|
66
|
+
this.client = this._createClient(this.wsUrl);
|
|
67
|
+
await this.client.connect();
|
|
68
|
+
await this.client.auth(this.token);
|
|
69
|
+
this.client.onEvent((message) => this._onServerEvent(message));
|
|
70
|
+
const created = await this.client.request("remote-session-create", {
|
|
71
|
+
sessionId: this.agentSessionId,
|
|
72
|
+
name: this.name,
|
|
73
|
+
scopes: this.scopes,
|
|
74
|
+
});
|
|
75
|
+
this.remoteSessionId = created.session.sessionId;
|
|
76
|
+
this.pairing = created.pairing;
|
|
77
|
+
return this;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Pairing descriptor for devices. Relay-configured servers return a ready
|
|
82
|
+
* E2EE URI; otherwise build the direct-LAN URI from the caller's endpoint.
|
|
83
|
+
*/
|
|
84
|
+
pairingInfo({ lanWsUrl = null } = {}) {
|
|
85
|
+
if (!this.pairing) return null;
|
|
86
|
+
const uri =
|
|
87
|
+
this.pairing.uri ||
|
|
88
|
+
buildDirectPairingUri({
|
|
89
|
+
wsUrl: lanWsUrl || this.wsUrl,
|
|
90
|
+
serverToken: this.token,
|
|
91
|
+
remoteSessionId: this.remoteSessionId,
|
|
92
|
+
agentSessionId: this.agentSessionId,
|
|
93
|
+
pairingToken: this.pairing.token,
|
|
94
|
+
scopes: this.pairing.scopes,
|
|
95
|
+
expiresAt: this.pairing.expiresAt,
|
|
96
|
+
});
|
|
97
|
+
return {
|
|
98
|
+
uri,
|
|
99
|
+
remoteSessionId: this.remoteSessionId,
|
|
100
|
+
scopes: this.pairing.scopes,
|
|
101
|
+
expiresAt: this.pairing.expiresAt,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
_onServerEvent(message) {
|
|
106
|
+
if (
|
|
107
|
+
message?.type !== "remote-session-control" ||
|
|
108
|
+
message.remoteSessionId !== this.remoteSessionId ||
|
|
109
|
+
message.event?.type !== "approval.resolve"
|
|
110
|
+
) {
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
const requestId = message.event.requestId || message.event.approvalId;
|
|
114
|
+
const pending = requestId ? this._pending.get(requestId) : null;
|
|
115
|
+
if (!pending) return;
|
|
116
|
+
this._pending.delete(requestId);
|
|
117
|
+
clearTimeout(pending.timer);
|
|
118
|
+
const answer = message.event.answer ?? message.event.approved;
|
|
119
|
+
pending.resolve({
|
|
120
|
+
approved: answer === true || answer === "true" || answer === "yes",
|
|
121
|
+
via: "remote",
|
|
122
|
+
from: message.from || null,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Number of devices that can approve (excludes this host). */
|
|
127
|
+
async approverCount() {
|
|
128
|
+
try {
|
|
129
|
+
const devices = await this.client.request("remote-session-devices", {
|
|
130
|
+
remoteSessionId: this.remoteSessionId,
|
|
131
|
+
});
|
|
132
|
+
return (devices.devices || []).filter(
|
|
133
|
+
(device) => !device.isHost && device.scopes?.includes("approve"),
|
|
134
|
+
).length;
|
|
135
|
+
} catch {
|
|
136
|
+
return 0;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Publish a permission ask to paired devices and await the decision.
|
|
142
|
+
* Resolves `{approved, via, from}`; times out fail-closed (`approved:false,
|
|
143
|
+
* via:"timeout"`). The `permission.request` type triggers vendor push wake.
|
|
144
|
+
*/
|
|
145
|
+
requestDecision({
|
|
146
|
+
tool,
|
|
147
|
+
action = null,
|
|
148
|
+
detail = null,
|
|
149
|
+
timeoutMs,
|
|
150
|
+
onRequestId = null,
|
|
151
|
+
} = {}) {
|
|
152
|
+
const requestId = `ra-${process.pid}-${++this._counter}-${randomBytes(4).toString("hex")}`;
|
|
153
|
+
if (onRequestId) {
|
|
154
|
+
try {
|
|
155
|
+
onRequestId(requestId);
|
|
156
|
+
} catch {
|
|
157
|
+
// observer only
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return new Promise((resolve) => {
|
|
161
|
+
const timer = setTimeout(() => {
|
|
162
|
+
this._pending.delete(requestId);
|
|
163
|
+
this._publish({
|
|
164
|
+
type: "permission.resolved",
|
|
165
|
+
requestId,
|
|
166
|
+
approved: false,
|
|
167
|
+
via: "timeout",
|
|
168
|
+
});
|
|
169
|
+
resolve({ approved: false, via: "timeout", from: null });
|
|
170
|
+
}, timeoutMs || this.decisionTimeoutMs);
|
|
171
|
+
if (typeof timer.unref === "function") timer.unref();
|
|
172
|
+
this._pending.set(requestId, {
|
|
173
|
+
timer,
|
|
174
|
+
resolve: (decision) => {
|
|
175
|
+
this._publish({
|
|
176
|
+
type: "permission.resolved",
|
|
177
|
+
requestId,
|
|
178
|
+
approved: decision.approved,
|
|
179
|
+
via: decision.via,
|
|
180
|
+
});
|
|
181
|
+
resolve(decision);
|
|
182
|
+
},
|
|
183
|
+
});
|
|
184
|
+
this._publish({
|
|
185
|
+
type: "permission.request",
|
|
186
|
+
requestId,
|
|
187
|
+
tool: tool || null,
|
|
188
|
+
action,
|
|
189
|
+
detail,
|
|
190
|
+
askedAt: this._now(),
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Settle a pending ask locally (e.g. the terminal user answered first). */
|
|
196
|
+
resolveLocally(requestId, approved) {
|
|
197
|
+
const pending = this._pending.get(requestId);
|
|
198
|
+
if (!pending) return false;
|
|
199
|
+
this._pending.delete(requestId);
|
|
200
|
+
clearTimeout(pending.timer);
|
|
201
|
+
pending.resolve({ approved, via: "local", from: null });
|
|
202
|
+
return true;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
_publish(event) {
|
|
206
|
+
if (this._closed || !this.client) return;
|
|
207
|
+
this.client
|
|
208
|
+
.request("remote-session-publish", {
|
|
209
|
+
remoteSessionId: this.remoteSessionId,
|
|
210
|
+
event,
|
|
211
|
+
})
|
|
212
|
+
.catch(() => {
|
|
213
|
+
// Best-effort — a device that missed the request simply cannot answer;
|
|
214
|
+
// the timeout keeps the gate fail-closed.
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* ApprovalGate confirmer (`async (ctx) => boolean`). With a `fallback`
|
|
220
|
+
* confirmer, the LOCAL prompt races the remote decision (first answer wins);
|
|
221
|
+
* without one (headless), the remote decision alone gates, fail-closed on
|
|
222
|
+
* timeout.
|
|
223
|
+
*/
|
|
224
|
+
makeConfirmer({ fallback = null, timeoutMs = null, onAsk = null } = {}) {
|
|
225
|
+
return async (ctx) => {
|
|
226
|
+
const ask = {
|
|
227
|
+
tool: ctx?.tool || ctx?.name || null,
|
|
228
|
+
action: ctx?.action || null,
|
|
229
|
+
detail:
|
|
230
|
+
typeof ctx?.command === "string"
|
|
231
|
+
? ctx.command
|
|
232
|
+
: ctx?.args
|
|
233
|
+
? JSON.stringify(ctx.args).slice(0, 2000)
|
|
234
|
+
: null,
|
|
235
|
+
};
|
|
236
|
+
if (onAsk) {
|
|
237
|
+
try {
|
|
238
|
+
onAsk(ask);
|
|
239
|
+
} catch {
|
|
240
|
+
// observer only
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
let requestId = null;
|
|
244
|
+
const remote = this.requestDecision({
|
|
245
|
+
...ask,
|
|
246
|
+
timeoutMs,
|
|
247
|
+
onRequestId: (id) => {
|
|
248
|
+
requestId = id;
|
|
249
|
+
},
|
|
250
|
+
});
|
|
251
|
+
if (!fallback) {
|
|
252
|
+
const decision = await remote;
|
|
253
|
+
return decision.approved;
|
|
254
|
+
}
|
|
255
|
+
const local = Promise.resolve()
|
|
256
|
+
.then(() => fallback(ctx))
|
|
257
|
+
.then((approved) => ({ approved: approved === true, via: "local" }));
|
|
258
|
+
const decision = await Promise.race([remote, local]);
|
|
259
|
+
// Local answer won → settle the remote ask too (publishes
|
|
260
|
+
// permission.resolved so device UIs clear the pending card).
|
|
261
|
+
if (decision.via === "local" && requestId) {
|
|
262
|
+
this.resolveLocally(requestId, decision.approved);
|
|
263
|
+
}
|
|
264
|
+
return decision.approved;
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
async close() {
|
|
269
|
+
this._closed = true;
|
|
270
|
+
for (const [requestId, pending] of this._pending) {
|
|
271
|
+
clearTimeout(pending.timer);
|
|
272
|
+
pending.resolve({ approved: false, via: "closed", from: null });
|
|
273
|
+
this._pending.delete(requestId);
|
|
274
|
+
}
|
|
275
|
+
try {
|
|
276
|
+
if (this.client && this.remoteSessionId) {
|
|
277
|
+
await this.client
|
|
278
|
+
.request("remote-session-close", {
|
|
279
|
+
remoteSessionId: this.remoteSessionId,
|
|
280
|
+
})
|
|
281
|
+
.catch(() => undefined);
|
|
282
|
+
}
|
|
283
|
+
} finally {
|
|
284
|
+
this.client?.close();
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* One-call assembly for headless runs (`cc agent -p --remote-control`):
|
|
291
|
+
* self-hosts a lightweight WS server on an OS-assigned port (no sessionManager
|
|
292
|
+
* needed — the session stays client-hosted), starts a bridge for this run's
|
|
293
|
+
* session, prints the pairing URI (+ optional QR) to stderr, and returns the
|
|
294
|
+
* gate confirmer + teardown. Relay settings (env/config) flow through, so a
|
|
295
|
+
* configured relay yields an E2EE pairing URI reaching devices off-LAN.
|
|
296
|
+
*/
|
|
297
|
+
export async function startHeadlessRemoteApproval({
|
|
298
|
+
agentSessionId,
|
|
299
|
+
env = process.env,
|
|
300
|
+
config = {},
|
|
301
|
+
writeErr = () => {},
|
|
302
|
+
isText = false,
|
|
303
|
+
decisionTimeoutMs = undefined,
|
|
304
|
+
deps = {},
|
|
305
|
+
} = {}) {
|
|
306
|
+
const { ChainlessChainWSServer } =
|
|
307
|
+
deps.serverModule || (await import("../gateways/ws/ws-server.js"));
|
|
308
|
+
const resolved = resolveRemoteControlOptions({ flags: {}, env, config });
|
|
309
|
+
const server = new ChainlessChainWSServer({
|
|
310
|
+
port: 0,
|
|
311
|
+
host: resolved.host,
|
|
312
|
+
token: resolved.token,
|
|
313
|
+
remoteSessionRelayUrl: resolved.relayUrl,
|
|
314
|
+
remoteSessionPeerId: resolved.peerId,
|
|
315
|
+
});
|
|
316
|
+
await server.start();
|
|
317
|
+
const bridge = new RemoteApprovalBridge({
|
|
318
|
+
wsUrl: `ws://127.0.0.1:${server.port}`,
|
|
319
|
+
token: resolved.token,
|
|
320
|
+
agentSessionId,
|
|
321
|
+
scopes: ["observe", "approve"],
|
|
322
|
+
decisionTimeoutMs,
|
|
323
|
+
});
|
|
324
|
+
try {
|
|
325
|
+
await bridge.start();
|
|
326
|
+
} catch (err) {
|
|
327
|
+
await server.stop().catch(() => undefined);
|
|
328
|
+
throw err;
|
|
329
|
+
}
|
|
330
|
+
const lanAddress = deps.lanAddress || pickLanAddress() || "127.0.0.1";
|
|
331
|
+
const pairing = bridge.pairingInfo({
|
|
332
|
+
lanWsUrl: `ws://${lanAddress}:${server.port}`,
|
|
333
|
+
});
|
|
334
|
+
if (isText) {
|
|
335
|
+
writeErr(
|
|
336
|
+
" remote-control: approvals can be answered from a paired device\n",
|
|
337
|
+
);
|
|
338
|
+
writeErr(` pairing: ${pairing.uri}\n`);
|
|
339
|
+
const qr = await renderQrCode(pairing.uri, deps);
|
|
340
|
+
if (qr) writeErr(qr + "\n");
|
|
341
|
+
}
|
|
342
|
+
return {
|
|
343
|
+
pairing,
|
|
344
|
+
server,
|
|
345
|
+
bridge,
|
|
346
|
+
confirmer: bridge.makeConfirmer({
|
|
347
|
+
onAsk: (ask) => {
|
|
348
|
+
if (isText) {
|
|
349
|
+
writeErr(
|
|
350
|
+
` permission(${ask.tool || "?"}): waiting for remote approval…\n`,
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
},
|
|
354
|
+
}),
|
|
355
|
+
close: async () => {
|
|
356
|
+
await bridge.close().catch(() => undefined);
|
|
357
|
+
await server.stop().catch(() => undefined);
|
|
358
|
+
},
|
|
359
|
+
};
|
|
360
|
+
}
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* remote-control core — option resolution, pairing payloads, and discovery
|
|
3
|
+
* state for the unified `cc remote-control` entry (gap-analysis 第四阶段 #1).
|
|
4
|
+
*
|
|
5
|
+
* The heavy lifting (WS server, remote-session registry, E2EE relay, push)
|
|
6
|
+
* already exists under src/gateways + src/harness; this module only composes
|
|
7
|
+
* it into one entry point:
|
|
8
|
+
*
|
|
9
|
+
* - resolveRemoteControlOptions() flags > env > config > defaults
|
|
10
|
+
* - buildDirectPairingUri() LAN pairing descriptor (no relay needed)
|
|
11
|
+
* - parseDirectPairingUri() inverse, used by clients + tests
|
|
12
|
+
* - pickLanAddress() best-effort non-internal IPv4
|
|
13
|
+
* - state file read/write ~/.chainlesschain/remote-control/<port>.json
|
|
14
|
+
* so `cc remote-control status/stop` can discover a running host process.
|
|
15
|
+
* The file is 0600 and CONTAINS THE SERVER TOKEN — same local trust model
|
|
16
|
+
* as ~/.chainlesschain/ide/<port>.json (readable only by the OS user).
|
|
17
|
+
* - renderQrCode() lazy OPTIONAL `qrcode` import; returns
|
|
18
|
+
* null when the package is not installed (URI printing is the contract,
|
|
19
|
+
* the QR is progressive enhancement — no hard dependency, trap #6/#27).
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import fs from "fs";
|
|
23
|
+
import os from "os";
|
|
24
|
+
import path from "path";
|
|
25
|
+
import { randomBytes } from "crypto";
|
|
26
|
+
import { REMOTE_SESSION_SCOPES } from "../harness/remote-session-registry.js";
|
|
27
|
+
|
|
28
|
+
export const REMOTE_CONTROL_DEFAULT_PORT = 18800;
|
|
29
|
+
export const REMOTE_CONTROL_PAIRING_SCHEME =
|
|
30
|
+
"chainlesschain://remote-control/pair#";
|
|
31
|
+
export const REMOTE_CONTROL_DEFAULT_SCOPES = Object.freeze([
|
|
32
|
+
"observe",
|
|
33
|
+
"prompt",
|
|
34
|
+
"approve",
|
|
35
|
+
"interrupt",
|
|
36
|
+
]);
|
|
37
|
+
|
|
38
|
+
function b64url(value) {
|
|
39
|
+
return Buffer.from(value, "utf8").toString("base64url");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function unb64url(value) {
|
|
43
|
+
return Buffer.from(String(value), "base64url").toString("utf8");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Parse a comma/space separated scope list, validating against the registry. */
|
|
47
|
+
export function parseScopes(raw) {
|
|
48
|
+
if (raw == null || raw === "") return [...REMOTE_CONTROL_DEFAULT_SCOPES];
|
|
49
|
+
const scopes = String(raw)
|
|
50
|
+
.split(/[\s,]+/)
|
|
51
|
+
.map((scope) => scope.trim())
|
|
52
|
+
.filter(Boolean);
|
|
53
|
+
if (scopes.length === 0) return [...REMOTE_CONTROL_DEFAULT_SCOPES];
|
|
54
|
+
for (const scope of scopes) {
|
|
55
|
+
if (!REMOTE_SESSION_SCOPES.includes(scope)) {
|
|
56
|
+
throw new Error(
|
|
57
|
+
`Unknown remote-control scope "${scope}". Valid: ${REMOTE_SESSION_SCOPES.join(", ")}`,
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return [...new Set(scopes)];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Resolve effective start options. Precedence: explicit flags > environment >
|
|
66
|
+
* config (`remoteControl` / legacy `remoteSession` blocks) > defaults. A
|
|
67
|
+
* missing token is AUTO-GENERATED (32 hex chars) — the WS server must never
|
|
68
|
+
* come up unauthenticated on the unified entry, even on loopback, because the
|
|
69
|
+
* pairing URI embeds the token and pairing is the whole point.
|
|
70
|
+
*/
|
|
71
|
+
export function resolveRemoteControlOptions({
|
|
72
|
+
flags = {},
|
|
73
|
+
env = process.env,
|
|
74
|
+
config = {},
|
|
75
|
+
} = {}) {
|
|
76
|
+
const rc = config.remoteControl || {};
|
|
77
|
+
const legacy = config.remoteSession || {};
|
|
78
|
+
const port = Number(
|
|
79
|
+
flags.port ?? env.CC_REMOTE_CONTROL_PORT ?? rc.port ?? undefined,
|
|
80
|
+
);
|
|
81
|
+
const resolvedPort =
|
|
82
|
+
Number.isFinite(port) && port >= 1 && port <= 65535
|
|
83
|
+
? Math.floor(port)
|
|
84
|
+
: REMOTE_CONTROL_DEFAULT_PORT;
|
|
85
|
+
const token =
|
|
86
|
+
flags.token ||
|
|
87
|
+
env.CC_REMOTE_CONTROL_TOKEN ||
|
|
88
|
+
rc.token ||
|
|
89
|
+
randomBytes(16).toString("hex");
|
|
90
|
+
const relayUrl =
|
|
91
|
+
flags.relayUrl ||
|
|
92
|
+
env.CC_REMOTE_SESSION_RELAY_URL ||
|
|
93
|
+
rc.relayUrl ||
|
|
94
|
+
legacy.relayUrl ||
|
|
95
|
+
null;
|
|
96
|
+
const peerId =
|
|
97
|
+
flags.peerId ||
|
|
98
|
+
env.CC_REMOTE_SESSION_PEER_ID ||
|
|
99
|
+
rc.peerId ||
|
|
100
|
+
legacy.peerId ||
|
|
101
|
+
(relayUrl
|
|
102
|
+
? `cc-host-${os.hostname()}-${randomBytes(4).toString("hex")}`
|
|
103
|
+
: null);
|
|
104
|
+
return {
|
|
105
|
+
port: resolvedPort,
|
|
106
|
+
host: flags.host || rc.host || "0.0.0.0",
|
|
107
|
+
token,
|
|
108
|
+
relayUrl,
|
|
109
|
+
peerId,
|
|
110
|
+
scopes: parseScopes(flags.scopes ?? rc.scopes ?? null),
|
|
111
|
+
name: flags.name || rc.name || `remote-control @ ${os.hostname()}`,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** First non-internal IPv4 address, or null. Pure over an interfaces map. */
|
|
116
|
+
export function pickLanAddress(interfaces = os.networkInterfaces()) {
|
|
117
|
+
for (const entries of Object.values(interfaces || {})) {
|
|
118
|
+
for (const entry of entries || []) {
|
|
119
|
+
const family =
|
|
120
|
+
entry.family === "IPv4" || entry.family === 4 ? "IPv4" : entry.family;
|
|
121
|
+
if (family === "IPv4" && !entry.internal && entry.address) {
|
|
122
|
+
return entry.address;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Direct (LAN) pairing descriptor for hosts without a signaling relay: the
|
|
131
|
+
* device connects straight to the host's WS endpoint, authenticates with the
|
|
132
|
+
* embedded server token, then joins the remote session with the one-time
|
|
133
|
+
* pairing token. Encodes as `chainlesschain://remote-control/pair#<b64url json>`
|
|
134
|
+
* so the same string works as QR payload and deep link.
|
|
135
|
+
*/
|
|
136
|
+
export function buildDirectPairingUri({
|
|
137
|
+
wsUrl,
|
|
138
|
+
serverToken,
|
|
139
|
+
remoteSessionId,
|
|
140
|
+
agentSessionId,
|
|
141
|
+
pairingToken,
|
|
142
|
+
scopes,
|
|
143
|
+
expiresAt,
|
|
144
|
+
}) {
|
|
145
|
+
if (!wsUrl || !remoteSessionId || !pairingToken) {
|
|
146
|
+
throw new Error("wsUrl, remoteSessionId and pairingToken are required");
|
|
147
|
+
}
|
|
148
|
+
const payload = {
|
|
149
|
+
v: 1,
|
|
150
|
+
transport: "direct",
|
|
151
|
+
wsUrl,
|
|
152
|
+
serverToken: serverToken || null,
|
|
153
|
+
remoteSessionId,
|
|
154
|
+
agentSessionId: agentSessionId || null,
|
|
155
|
+
pairingToken,
|
|
156
|
+
scopes: Array.isArray(scopes) ? scopes : null,
|
|
157
|
+
expiresAt: expiresAt ?? null,
|
|
158
|
+
};
|
|
159
|
+
return REMOTE_CONTROL_PAIRING_SCHEME + b64url(JSON.stringify(payload));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Inverse of buildDirectPairingUri. Throws on malformed input. */
|
|
163
|
+
export function parseDirectPairingUri(uri) {
|
|
164
|
+
if (
|
|
165
|
+
typeof uri !== "string" ||
|
|
166
|
+
!uri.startsWith(REMOTE_CONTROL_PAIRING_SCHEME)
|
|
167
|
+
) {
|
|
168
|
+
throw new Error("Not a remote-control pairing URI");
|
|
169
|
+
}
|
|
170
|
+
const payload = JSON.parse(
|
|
171
|
+
unb64url(uri.slice(REMOTE_CONTROL_PAIRING_SCHEME.length)),
|
|
172
|
+
);
|
|
173
|
+
if (payload.v !== 1 || payload.transport !== "direct") {
|
|
174
|
+
throw new Error(
|
|
175
|
+
`Unsupported remote-control pairing payload (v=${payload.v}, transport=${payload.transport})`,
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
return payload;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ─── discovery state files ────────────────────────────────────────────────
|
|
182
|
+
|
|
183
|
+
export function remoteControlStateDir(homedir = os.homedir()) {
|
|
184
|
+
return path.join(homedir, ".chainlesschain", "remote-control");
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function stateFilePath(dir, port) {
|
|
188
|
+
return path.join(dir, `${port}.json`);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Persist a running host's discovery record (0600 file, 0700 dir). Contains
|
|
193
|
+
* the server token — local-user trust domain only, mirroring the IDE bridge.
|
|
194
|
+
*/
|
|
195
|
+
export function writeRemoteControlState(state, { dir } = {}) {
|
|
196
|
+
const stateDir = dir || remoteControlStateDir();
|
|
197
|
+
fs.mkdirSync(stateDir, { recursive: true, mode: 0o700 });
|
|
198
|
+
const file = stateFilePath(stateDir, state.port);
|
|
199
|
+
fs.writeFileSync(file, JSON.stringify(state, null, 2), {
|
|
200
|
+
encoding: "utf-8",
|
|
201
|
+
mode: 0o600,
|
|
202
|
+
});
|
|
203
|
+
return file;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export function removeRemoteControlState(port, { dir } = {}) {
|
|
207
|
+
const file = stateFilePath(dir || remoteControlStateDir(), port);
|
|
208
|
+
try {
|
|
209
|
+
fs.unlinkSync(file);
|
|
210
|
+
return true;
|
|
211
|
+
} catch {
|
|
212
|
+
return false;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export function isPidAlive(pid) {
|
|
217
|
+
if (!Number.isFinite(pid) || pid <= 0) return false;
|
|
218
|
+
try {
|
|
219
|
+
process.kill(pid, 0);
|
|
220
|
+
return true;
|
|
221
|
+
} catch (err) {
|
|
222
|
+
// EPERM = alive but owned elsewhere; ESRCH = gone.
|
|
223
|
+
return err.code === "EPERM";
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* All discovery records with liveness annotated. Unreadable/corrupt files are
|
|
229
|
+
* reported as `invalid` rather than thrown (a stale half-written file must
|
|
230
|
+
* never break `status`).
|
|
231
|
+
*/
|
|
232
|
+
export function readRemoteControlStates({ dir } = {}) {
|
|
233
|
+
const stateDir = dir || remoteControlStateDir();
|
|
234
|
+
let names = [];
|
|
235
|
+
try {
|
|
236
|
+
names = fs.readdirSync(stateDir).filter((name) => name.endsWith(".json"));
|
|
237
|
+
} catch {
|
|
238
|
+
return [];
|
|
239
|
+
}
|
|
240
|
+
const states = [];
|
|
241
|
+
for (const name of names) {
|
|
242
|
+
const file = path.join(stateDir, name);
|
|
243
|
+
try {
|
|
244
|
+
const state = JSON.parse(fs.readFileSync(file, "utf-8"));
|
|
245
|
+
states.push({
|
|
246
|
+
...state,
|
|
247
|
+
stateFile: file,
|
|
248
|
+
alive: isPidAlive(state.pid),
|
|
249
|
+
});
|
|
250
|
+
} catch {
|
|
251
|
+
states.push({ stateFile: file, invalid: true, alive: false });
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return states;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Terminal QR for a pairing URI via the OPTIONAL `qrcode` package. Returns
|
|
259
|
+
* null when unavailable/failed — callers always print the URI regardless.
|
|
260
|
+
*/
|
|
261
|
+
export async function renderQrCode(text, _deps = {}) {
|
|
262
|
+
try {
|
|
263
|
+
const importer = _deps.importer || ((spec) => import(spec));
|
|
264
|
+
const mod = await importer("qrcode");
|
|
265
|
+
const qrcode = mod.default || mod;
|
|
266
|
+
return await qrcode.toString(text, { type: "terminal", small: true });
|
|
267
|
+
} catch {
|
|
268
|
+
return null;
|
|
269
|
+
}
|
|
270
|
+
}
|
package/src/lib/repl-denials.js
CHANGED
|
@@ -61,6 +61,10 @@ export function classifyDenial(ev = {}) {
|
|
|
61
61
|
(shellPol ? "shell-policy" : viaFromPrefix(errStr)) ||
|
|
62
62
|
"policy";
|
|
63
63
|
const rule = (policy && policy.rule) || (shellPol && shellPol.ruleId) || null;
|
|
64
|
+
const chain =
|
|
65
|
+
result && Array.isArray(result.permissionChain)
|
|
66
|
+
? result.permissionChain
|
|
67
|
+
: null;
|
|
64
68
|
return {
|
|
65
69
|
tool: tool || "?",
|
|
66
70
|
summary: String(
|
|
@@ -69,9 +73,29 @@ export function classifyDenial(ev = {}) {
|
|
|
69
73
|
reason: errStr || "(denied)",
|
|
70
74
|
via,
|
|
71
75
|
rule,
|
|
76
|
+
...(chain ? { chain } : {}),
|
|
72
77
|
};
|
|
73
78
|
}
|
|
74
79
|
|
|
80
|
+
/**
|
|
81
|
+
* One compact line for a layer-by-layer permission chain, e.g.
|
|
82
|
+
* `settings-rules→no-match · shell-policy→warn · approval-gate→deny (user-deny, trusted)`.
|
|
83
|
+
* Returns null when there is nothing to explain.
|
|
84
|
+
*/
|
|
85
|
+
export function formatDenialChain(chain) {
|
|
86
|
+
if (!Array.isArray(chain) || chain.length === 0) return null;
|
|
87
|
+
const parts = chain.map((step) => {
|
|
88
|
+
const base = `${step.layer}→${step.outcome ?? "?"}`;
|
|
89
|
+
const detail = [
|
|
90
|
+
step.rule ? String(step.rule) : null,
|
|
91
|
+
step.via && step.via !== step.outcome ? String(step.via) : null,
|
|
92
|
+
step.policy ? String(step.policy) : null,
|
|
93
|
+
].filter(Boolean);
|
|
94
|
+
return detail.length ? `${base} (${detail.join(", ")})` : base;
|
|
95
|
+
});
|
|
96
|
+
return parts.join(" · ");
|
|
97
|
+
}
|
|
98
|
+
|
|
75
99
|
/** Two denials are "the same" attempt when tool + command + rule + via match. */
|
|
76
100
|
function sameDenial(a, b) {
|
|
77
101
|
return (
|
|
@@ -131,6 +155,8 @@ export function formatDenials(log, opts = {}) {
|
|
|
131
155
|
const what = d.summary ? `${d.tool} ${d.summary}` : d.tool || "?";
|
|
132
156
|
const times = d.count > 1 ? ` ×${d.count}` : "";
|
|
133
157
|
lines.push(` • ${what}${times} [${where}${ago}]`);
|
|
158
|
+
const chainLine = formatDenialChain(d.chain);
|
|
159
|
+
if (chainLine) lines.push(` chain: ${chainLine}`);
|
|
134
160
|
if (d.reason) lines.push(` ${d.reason}`);
|
|
135
161
|
}
|
|
136
162
|
return lines.join("\n");
|
|
@@ -87,5 +87,9 @@ export async function evaluateShellCommandWithApproval({
|
|
|
87
87
|
shellPolicy,
|
|
88
88
|
riskLevel,
|
|
89
89
|
policy: gateResult.policy,
|
|
90
|
+
// Explainability passthrough (autoMode.decisions wrapper attaches these):
|
|
91
|
+
// which gate rule fired and why — surfaced in the permission chain.
|
|
92
|
+
gateReason: gateResult.reason ?? null,
|
|
93
|
+
gateRule: gateResult.rule ?? null,
|
|
90
94
|
};
|
|
91
95
|
}
|