chainlesschain 0.162.161 → 0.162.162
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/assets/{AIOps-DMn528q-.js → AIOps-ClI2ALgv.js} +1 -1
- package/src/assets/web-panel/assets/{ActionButton-D9ZcDCJL.js → ActionButton-BDjRcJaK.js} +1 -1
- package/src/assets/web-panel/assets/{Analytics-A_K-8PX0.js → Analytics-DikSRsaT.js} +3 -3
- package/src/assets/web-panel/assets/{AppLayout-D4pFW3X6.js → AppLayout-9mLhMFmz.js} +5 -5
- package/src/assets/web-panel/assets/{Artifacts-Dz46COAd.js → Artifacts-BSr88BrH.js} +1 -1
- package/src/assets/web-panel/assets/{Audit-cbMjbyg0.js → Audit-CdvyimOF.js} +1 -1
- package/src/assets/web-panel/assets/{BackgroundAgents-Dtf88GAj.js → BackgroundAgents-D3_hCedh.js} +1 -1
- package/src/assets/web-panel/assets/{Backup-C5KRxn52.js → Backup-Cyvv9RHf.js} +1 -1
- package/src/assets/web-panel/assets/{BaseInput-Dd2UK3BN.js → BaseInput-BZM14kt8.js} +1 -1
- package/src/assets/web-panel/assets/{Chat-lptKTFdG.js → Chat-DrQSokVz.js} +6 -6
- package/src/assets/web-panel/assets/{ChatBubbleRenderer-C2KLtwHz.js → ChatBubbleRenderer-HwJR-C6B.js} +1 -1
- package/src/assets/web-panel/assets/{Checkbox-_yspG6rG.js → Checkbox-bFLYeWMV.js} +1 -1
- package/src/assets/web-panel/assets/{Codegen-BGq_GAha.js → Codegen-7mN7xJXT.js} +1 -1
- package/src/assets/web-panel/assets/{Col-CePor71g.js → Col-DAB5ZWm7.js} +1 -1
- package/src/assets/web-panel/assets/{Community-CLOWhIT7.js → Community-B_1TymaK.js} +1 -1
- package/src/assets/web-panel/assets/{Compact-CivJt2vT.js → Compact-Bq1uSWLJ.js} +1 -1
- package/src/assets/web-panel/assets/{Compliance-Btmo1GGE.js → Compliance-CyiE1jEe.js} +1 -1
- package/src/assets/web-panel/assets/{Cowork-Ct35OlG4.js → Cowork-Cpk0D7vm.js} +2 -2
- package/src/assets/web-panel/assets/{Cron-CTc2GmhB.js → Cron-d_Lv3rdB.js} +2 -2
- package/src/assets/web-panel/assets/{Crosschain-ZtiBETk1.js → Crosschain-DMqhHjMR.js} +1 -1
- package/src/assets/web-panel/assets/{DID-BNqMQQXw.js → DID-Br-oPuBw.js} +2 -2
- package/src/assets/web-panel/assets/{Dashboard-BmzZd-9V.js → Dashboard-CfLW_RaP.js} +2 -2
- package/src/assets/web-panel/assets/{Dropdown-CMVgvP2M.js → Dropdown-DZnTOExV.js} +1 -1
- package/src/assets/web-panel/assets/{EmailListRenderer-CcqQFAfT.js → EmailListRenderer-DHIUHDv1.js} +1 -1
- package/src/assets/web-panel/assets/{FamilyGuardDashboard-CTqB1_4V.js → FamilyGuardDashboard-Bw3t3myu.js} +1 -1
- package/src/assets/web-panel/assets/{Federation-CiKOOzlb.js → Federation-D9cpQPAY.js} +1 -1
- package/src/assets/web-panel/assets/{FormItemContext-Wk-OT0gr.js → FormItemContext-Jdczr9Mr.js} +1 -1
- package/src/assets/web-panel/assets/{GenericCardRenderer-DbEQT6ue.js → GenericCardRenderer-BT8ZMpC5.js} +1 -1
- package/src/assets/web-panel/assets/{Git-0ZvCPSD9.js → Git-DjARip2b.js} +2 -2
- package/src/assets/web-panel/assets/{Governance-CmVuKkPb.js → Governance-BgWbSRcj.js} +1 -1
- package/src/assets/web-panel/assets/{Inference-CTWE3Jwg.js → Inference-CfouJciM.js} +1 -1
- package/src/assets/web-panel/assets/{KnowledgeGraph-CBEIF00L.js → KnowledgeGraph-50l7mrAr.js} +1 -1
- package/src/assets/web-panel/assets/{Logs-DLhxDwVG.js → Logs-rq50H84B.js} +2 -2
- package/src/assets/web-panel/assets/MarkdownRenderer-DUPIEdQH.js +1 -0
- package/src/assets/web-panel/assets/{Marketplace-BgsCBrnc.js → Marketplace-BYgPDmHU.js} +1 -1
- package/src/assets/web-panel/assets/{McpTools-ClBUGDS2.js → McpTools-5DVgcFdx.js} +3 -3
- package/src/assets/web-panel/assets/{Memory-CnG_ZnYw.js → Memory-B70LPk7f.js} +2 -2
- package/src/assets/web-panel/assets/{MobileBridge-BUOAbR1Z.js → MobileBridge-De8ssA5Y.js} +1 -1
- package/src/assets/web-panel/assets/MobileProjects-D10VISps.js +1 -0
- package/src/assets/web-panel/assets/{Mtc-CLjt4kCD.js → Mtc-RJsOTsIA.js} +2 -2
- package/src/assets/web-panel/assets/{MtcAudit-f24U9Aes.js → MtcAudit-Crh-6BEK.js} +5 -5
- package/src/assets/web-panel/assets/{Multisig-GrmGQbSq.js → Multisig-wuoR5l3x.js} +3 -3
- package/src/assets/web-panel/assets/{NLProgramming-DADaWn-n.js → NLProgramming-U2Mji4fZ.js} +1 -1
- package/src/assets/web-panel/assets/{Notes-xtf5RX6C.js → Notes-CdTZAyDH.js} +3 -3
- package/src/assets/web-panel/assets/{NotificationSettings-Cd27Rgtx.js → NotificationSettings-C-FucWE7.js} +1 -1
- package/src/assets/web-panel/assets/{OrderTableRenderer-fXRxlDjl.js → OrderTableRenderer-DCYFTeaI.js} +1 -1
- package/src/assets/web-panel/assets/{Organization-joCFlNB0.js → Organization-an3OF85L.js} +4 -4
- package/src/assets/web-panel/assets/{Overflow-B1HoURq0.js → Overflow-C-1vmYFe.js} +1 -1
- package/src/assets/web-panel/assets/{P2P-DiwsKbT7.js → P2P-Cwh47xKg.js} +2 -2
- package/src/assets/web-panel/assets/{PdhVaultBrowser-Xe-BsW6S.js → PdhVaultBrowser-Dm38GT0k.js} +3 -3
- package/src/assets/web-panel/assets/{Permissions-C23qi8kl.js → Permissions-DS2Y8awD.js} +4 -4
- package/src/assets/web-panel/assets/{PersonalDataHub-BS1syX2f.js → PersonalDataHub-BQNziA0I.js} +3 -3
- package/src/assets/web-panel/assets/{Pipeline-DRYwwDyh.js → Pipeline-DF85L1ue.js} +1 -1
- package/src/assets/web-panel/assets/{Privacy-BQKaDHPH.js → Privacy-BuLornF-.js} +1 -1
- package/src/assets/web-panel/assets/{ProjectInit-BC6F9xiG.js → ProjectInit-C3Y31IwX.js} +2 -2
- package/src/assets/web-panel/assets/{ProjectSettings-ClFPc1Nd.js → ProjectSettings-BCPiI8aD.js} +2 -2
- package/src/assets/web-panel/assets/Projects-BZ6s4k6g.js +1 -0
- package/src/assets/web-panel/assets/{Providers-CLWuOUNt.js → Providers-D3Wp022X.js} +1 -1
- package/src/assets/web-panel/assets/{QrScannerModal-DiqULTxb.js → QrScannerModal-CUJfQkH4.js} +1 -1
- package/src/assets/web-panel/assets/{QuickAsk-9A03P-E_.js → QuickAsk-UAc0EO8l.js} +1 -1
- package/src/assets/web-panel/assets/{Recommend-CJZAnbq_.js → Recommend-BaxKupcs.js} +1 -1
- package/src/assets/web-panel/assets/{RemoteSession-C9A1R77h.js → RemoteSession-C75NNnXZ.js} +2 -2
- package/src/assets/web-panel/assets/{Reputation-BIZc6Rky.js → Reputation-BVXynZAk.js} +1 -1
- package/src/assets/web-panel/assets/{Row-DfIPNtuG.js → Row-C5MRZ0bi.js} +1 -1
- package/src/assets/web-panel/assets/{RssFeed-C5znFcQ1.js → RssFeed-DAKkEYka.js} +3 -3
- package/src/assets/web-panel/assets/{Search-DSs2z3OX.js → Search-DvWv9YOA.js} +1 -1
- package/src/assets/web-panel/assets/{Security-CV6LcrLY.js → Security-B5peafW1.js} +4 -4
- package/src/assets/web-panel/assets/{Services-cmhoX0Xj.js → Services-UQmvPEaG.js} +2 -2
- package/src/assets/web-panel/assets/{Skeleton-tCsg2po7.js → Skeleton-C3bhWpuF.js} +1 -1
- package/src/assets/web-panel/assets/{Skills-BK1KUDiy.js → Skills-DfkKf9_H.js} +1 -1
- package/src/assets/web-panel/assets/{Sla-yRgyeA82.js → Sla-DqMSjlBM.js} +1 -1
- package/src/assets/web-panel/assets/{SpeechSettings-c1mo1mlp.js → SpeechSettings-C-WbxWUX.js} +1 -1
- package/src/assets/web-panel/assets/{SyncSettings-koIAfuT3.js → SyncSettings-CYuHZ4N2.js} +2 -2
- package/src/assets/web-panel/assets/{Tasks-DkkreAaB.js → Tasks-CyeAyB9d.js} +1 -1
- package/src/assets/web-panel/assets/{Templates-fy5Ctf9M.js → Templates-BTCeQnpq.js} +1 -1
- package/src/assets/web-panel/assets/{Tenant-Bw70Jf6S.js → Tenant-BifQuA9Y.js} +1 -1
- package/src/assets/web-panel/assets/{Terminal-BKh6Rx-4.js → Terminal-Bx_9Bocf.js} +2 -2
- package/src/assets/web-panel/assets/{TimelineRenderer-Dg54WP0c.js → TimelineRenderer-BX0RFCOm.js} +1 -1
- package/src/assets/web-panel/assets/{Tokens-BvvD5SxJ.js → Tokens-CGHrCmUS.js} +1 -1
- package/src/assets/web-panel/assets/{Trigger-B7Ug9x4e.js → Trigger-DlqaY4SO.js} +1 -1
- package/src/assets/web-panel/assets/{Trust-BjvuMORY.js → Trust-DfOmj-ay.js} +1 -1
- package/src/assets/web-panel/assets/{UkeySign-CZqhkx8p.js → UkeySign-B72jH5fE.js} +1 -1
- package/src/assets/web-panel/assets/{VideoEditing-ClR50lhX.js → VideoEditing-BT-63fqv.js} +1 -1
- package/src/assets/web-panel/assets/{Wallet-DlfppYjx.js → Wallet-Dsul3sHr.js} +4 -4
- package/src/assets/web-panel/assets/{WebAuthn-BirgVr8B.js → WebAuthn-Izkwwdgy.js} +2 -2
- package/src/assets/web-panel/assets/{WorkflowEditor-B8DP_bax.js → WorkflowEditor-D3FHSiH1.js} +1 -1
- package/src/assets/web-panel/assets/{chat-DA8AUZzL.js → chat-CmVz5z3U.js} +1 -1
- package/src/assets/web-panel/assets/{colors-C38pLGp1.js → colors-CuLgyJBp.js} +1 -1
- package/src/assets/web-panel/assets/{compact-item-81rtr98o.js → compact-item-Ml1YkXpw.js} +1 -1
- package/src/assets/web-panel/assets/{createContext-D34ixq8t.js → createContext-BvjJ48bs.js} +1 -1
- package/src/assets/web-panel/assets/devWarning-DhiGrbsh.js +1 -0
- package/src/assets/web-panel/assets/{hasIn-D4bzqTrZ.js → hasIn-DVC0157C.js} +1 -1
- package/src/assets/web-panel/assets/{index-D_2VYPr1.js → index-4SBta3d7.js} +1 -1
- package/src/assets/web-panel/assets/{index-CoVgZw_b.js → index-4wcgvPoF.js} +1 -1
- package/src/assets/web-panel/assets/{index-ZRJRItm0.js → index-B0S8NzO6.js} +1 -1
- package/src/assets/web-panel/assets/index-B7a1TrU6.js +1 -0
- package/src/assets/web-panel/assets/{index-CuS-UPoj.js → index-BUt8kTIB.js} +1 -1
- package/src/assets/web-panel/assets/{index-BtGxuQrO.js → index-B_EvG_GW.js} +1 -1
- package/src/assets/web-panel/assets/{index-B0QArPQt.js → index-BbPtzB8M.js} +1 -1
- package/src/assets/web-panel/assets/{index-Cd3KuNK5.js → index-BbepULSc.js} +1 -1
- package/src/assets/web-panel/assets/{index-CbhE3j5j.js → index-BfuKP4qe.js} +1 -1
- package/src/assets/web-panel/assets/{index-BXd2_LmH.js → index-BiC6dQZG.js} +1 -1
- package/src/assets/web-panel/assets/{index-CP5alL4t.js → index-BjHBpRYZ.js} +1 -1
- package/src/assets/web-panel/assets/{index-BvL_3J_n.js → index-BpFJHiTv.js} +1 -1
- package/src/assets/web-panel/assets/{index-DFyWKOBD.js → index-BydjdZnl.js} +1 -1
- package/src/assets/web-panel/assets/{index-Ddk0e_Pi.js → index-BzG-x9p4.js} +1 -1
- package/src/assets/web-panel/assets/{index-BS3M5H9V.js → index-Bzr0rB5T.js} +1 -1
- package/src/assets/web-panel/assets/{index-DkHqMnn3.js → index-C229ABfy.js} +1 -1
- package/src/assets/web-panel/assets/index-C2B1KRja.js +1 -0
- package/src/assets/web-panel/assets/{index-D6GfvEQG.js → index-CFYVQMvu.js} +1 -1
- package/src/assets/web-panel/assets/{index-BqIhLAbA.js → index-CXkJH-lK.js} +1 -1
- package/src/assets/web-panel/assets/{index-B8mkLM4x.js → index-C_H_S-DY.js} +1 -1
- package/src/assets/web-panel/assets/{index-D0Ih7Hwn.js → index-C_XuRgGn.js} +1 -1
- package/src/assets/web-panel/assets/{index-COe_cOLJ.js → index-C_vRgTl9.js} +1 -1
- package/src/assets/web-panel/assets/{index-BIP62a6r.js → index-Ck_52dz_.js} +1 -1
- package/src/assets/web-panel/assets/{index-BP-zkXK8.js → index-CmFNvNLZ.js} +1 -1
- package/src/assets/web-panel/assets/{index-D3zCCZRS.js → index-CudmwNBV.js} +3 -3
- package/src/assets/web-panel/assets/{index-DFHa1HIg.js → index-Cw0Rjy87.js} +1 -1
- package/src/assets/web-panel/assets/{index-BvD7b2IK.js → index-D3lD5qap.js} +1 -1
- package/src/assets/web-panel/assets/{index-DakfFZ-k.js → index-DC_OI9H2.js} +1 -1
- package/src/assets/web-panel/assets/{index-DvfCEJG-.js → index-DDGaOWBV.js} +1 -1
- package/src/assets/web-panel/assets/{index-E1OJRXs4.js → index-DJyN28Et.js} +1 -1
- package/src/assets/web-panel/assets/{index-Dmo3dFHb.js → index-DYXHFr4r.js} +1 -1
- package/src/assets/web-panel/assets/{index-tKiC8T-4.js → index-Dt5KMjwJ.js} +1 -1
- package/src/assets/web-panel/assets/{index-fI5sXMLg.js → index-DuGszaWY.js} +1 -1
- package/src/assets/web-panel/assets/{index-DUQLXLQM.js → index-DukDVXCv.js} +1 -1
- package/src/assets/web-panel/assets/{index-DMgnRwrK.js → index-KMqhcF7l.js} +1 -1
- package/src/assets/web-panel/assets/{index-F76w1NiL.js → index-KjgKdZe-.js} +1 -1
- package/src/assets/web-panel/assets/{index-DBFSo1NF.js → index-QXzlT9KD.js} +1 -1
- package/src/assets/web-panel/assets/{index-BlxEphl7.js → index-vG4XSJ1d.js} +1 -1
- package/src/assets/web-panel/assets/{index-9qoVpPt6.js → index-wvb_FtaV.js} +1 -1
- package/src/assets/web-panel/assets/{initDefaultProps-ByyvflIR.js → initDefaultProps-BDgd2YZ8.js} +1 -1
- package/src/assets/web-panel/assets/{motion-B3FQm2By.js → motion-ErtrXdYe.js} +1 -1
- package/src/assets/web-panel/assets/{move-BSfns6Ef.js → move-_GPPNCgs.js} +1 -1
- package/src/assets/web-panel/assets/{mtc-parser-CTG1Ieri.js → mtc-parser-DRLL-8V2.js} +1 -1
- package/src/assets/web-panel/assets/{omit-DObM_2j6.js → omit-p86sl023.js} +1 -1
- package/src/assets/web-panel/assets/{pickAttrs-DOfelQ1t.js → pickAttrs-BTu7cMLH.js} +1 -1
- package/src/assets/web-panel/assets/{placementArrow-CHZILds_.js → placementArrow-BEKz6Isr.js} +1 -1
- package/src/assets/web-panel/assets/{responsiveObserve-REbucKSx.js → responsiveObserve-Ck42c9MP.js} +1 -1
- package/src/assets/web-panel/assets/{slide-C6EZzD7o.js → slide-1CEvuPsB.js} +1 -1
- package/src/assets/web-panel/assets/{statusUtils-BxkWS-g3.js → statusUtils-BDfdx1GJ.js} +1 -1
- package/src/assets/web-panel/assets/{styleChecker-Chklyoq6.js → styleChecker-B4K46m5w.js} +1 -1
- package/src/assets/web-panel/assets/{useFlexGapSupport-CRVwKrrI.js → useFlexGapSupport-DoIVpJNq.js} +1 -1
- package/src/assets/web-panel/assets/{useFs-DV24ik_J.js → useFs-DdAWi-ln.js} +1 -1
- package/src/assets/web-panel/assets/{usePersonalDataHub-C7vIo4Pg.js → usePersonalDataHub-iUu6k8dx.js} +1 -1
- package/src/assets/web-panel/assets/{vnode-By-v5GlF.js → vnode-DZz4R6Tw.js} +1 -1
- package/src/assets/web-panel/assets/{zoom-B1w9Am3r.js → zoom-DXlf9sA3.js} +1 -1
- package/src/assets/web-panel/index.html +1 -1
- package/src/commands/review.js +6 -1
- package/src/data/changelog.json +7 -2
- package/src/lib/agents.js +19 -0
- package/src/lib/background-agent-supervisor.js +34 -7
- package/src/lib/docs-drift.js +166 -0
- package/src/lib/doctor-checkup.js +76 -0
- package/src/lib/hook-event-bus.cjs +0 -0
- package/src/lib/json-schema-output.js +30 -1
- package/src/lib/json-schema-validate.js +691 -0
- package/src/lib/lsp/__tests__/diagnostics-scheduler.test.js +93 -0
- package/src/lib/lsp/diagnostics-scheduler.js +127 -0
- package/src/lib/operation-fingerprint.js +95 -0
- package/src/lib/plugin-runtime/capabilities.js +371 -0
- package/src/lib/plugin-runtime/manifest.js +30 -0
- package/src/lib/remote-approval-bridge.js +16 -0
- package/src/lib/review-pipeline.js +227 -0
- package/src/lib/runtime-checkup.js +318 -0
- package/src/lib/session-lifecycle.js +305 -0
- package/src/lib/settings-hook-events.cjs +39 -13
- package/src/lib/subagent-contract.js +383 -0
- package/src/lib/telemetry-ids.js +190 -0
- package/src/lib/turn-binding.js +397 -0
- package/src/runtime/agent-core.js +13 -6
- package/src/assets/web-panel/assets/MarkdownRenderer-DxywvhTU.js +0 -1
- package/src/assets/web-panel/assets/MobileProjects-Dnt_9-WB.js +0 -1
- package/src/assets/web-panel/assets/Projects-Dn-f5aYe.js +0 -1
- package/src/assets/web-panel/assets/devWarning-CHDIEOeN.js +0 -1
- package/src/assets/web-panel/assets/index-CY1NURWZ.js +0 -1
- package/src/assets/web-panel/assets/index-DokUYqpq.js +0 -1
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Edit-triggered auto-diagnostics scheduler (P2) — debounce, throttle, and
|
|
3
|
+
* token-cap. Pure + clock-injected.
|
|
4
|
+
*/
|
|
5
|
+
import { describe, it, expect } from "vitest";
|
|
6
|
+
import {
|
|
7
|
+
DiagnosticsScheduler,
|
|
8
|
+
capDiagnostics,
|
|
9
|
+
} from "../diagnostics-scheduler.js";
|
|
10
|
+
|
|
11
|
+
describe("DiagnosticsScheduler — debounce", () => {
|
|
12
|
+
it("coalesces a burst of edits into one run after the quiet period", () => {
|
|
13
|
+
const s = new DiagnosticsScheduler({ debounceMs: 300, throttleMs: 1000 });
|
|
14
|
+
s.noteEdit("f.js", 0).noteEdit("f.js", 100).noteEdit("f.js", 200);
|
|
15
|
+
expect(s.due(400)).toEqual([]); // last edit at 200, only 200ms quiet
|
|
16
|
+
expect(s.due(500)).toEqual(["f.js"]); // 300ms after the last edit
|
|
17
|
+
expect(s.pendingCount()).toBe(0);
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
describe("DiagnosticsScheduler — throttle", () => {
|
|
22
|
+
it("won't run the same file again within the throttle window", () => {
|
|
23
|
+
const s = new DiagnosticsScheduler({ debounceMs: 100, throttleMs: 1000 });
|
|
24
|
+
s.noteEdit("f.js", 0);
|
|
25
|
+
expect(s.due(200)).toEqual(["f.js"]); // first run at 200
|
|
26
|
+
s.noteEdit("f.js", 300);
|
|
27
|
+
expect(s.due(500)).toEqual([]); // throttled (only 300ms since last run)
|
|
28
|
+
expect(s.pendingCount()).toBe(1); // stays pending
|
|
29
|
+
expect(s.due(1300)).toEqual(["f.js"]); // window passed → runs
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe("DiagnosticsScheduler — msUntilNextDue", () => {
|
|
34
|
+
it("reports the earliest time a pending file can run", () => {
|
|
35
|
+
const s = new DiagnosticsScheduler({ debounceMs: 300, throttleMs: 1000 });
|
|
36
|
+
s.noteEdit("f.js", 100);
|
|
37
|
+
expect(s.msUntilNextDue(200)).toBe(200); // ready at 400, now 200
|
|
38
|
+
expect(s.msUntilNextDue(500)).toBe(0); // already due
|
|
39
|
+
s.reset();
|
|
40
|
+
expect(s.msUntilNextDue(0)).toBeNull();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("independent files debounce independently", () => {
|
|
44
|
+
const s = new DiagnosticsScheduler({ debounceMs: 300, throttleMs: 1000 });
|
|
45
|
+
s.noteEdit("a.js", 0).noteEdit("b.js", 200);
|
|
46
|
+
expect(s.due(350).sort()).toEqual(["a.js"]); // b still settling
|
|
47
|
+
expect(s.due(600)).toEqual(["b.js"]);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe("capDiagnostics — token cap, least-severe dropped first", () => {
|
|
52
|
+
const mk = (severity, message) => ({ severity, message });
|
|
53
|
+
|
|
54
|
+
it("keeps within the token budget and drops the tail", () => {
|
|
55
|
+
const long = "x".repeat(400); // ~106 tokens each
|
|
56
|
+
const diags = [
|
|
57
|
+
mk("error", long),
|
|
58
|
+
mk("error", long),
|
|
59
|
+
mk("warning", long),
|
|
60
|
+
mk("hint", long),
|
|
61
|
+
];
|
|
62
|
+
const r = capDiagnostics(diags, { maxTokens: 220 });
|
|
63
|
+
expect(r.kept.length).toBe(2);
|
|
64
|
+
expect(r.truncated).toBe(true);
|
|
65
|
+
expect(r.dropped).toBe(2);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("prioritizes by severity — errors kept, hints dropped", () => {
|
|
69
|
+
const long = "y".repeat(400);
|
|
70
|
+
const diags = [mk("hint", long), mk("error", long), mk("warning", long)];
|
|
71
|
+
const r = capDiagnostics(diags, { maxTokens: 120 });
|
|
72
|
+
expect(r.kept).toHaveLength(1);
|
|
73
|
+
expect(r.kept[0].severity).toBe("error"); // most severe survived
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("accepts numeric LSP severities (1=error … 4=hint)", () => {
|
|
77
|
+
const long = "z".repeat(400);
|
|
78
|
+
const r = capDiagnostics([mk(4, long), mk(1, long)], { maxTokens: 120 });
|
|
79
|
+
expect(r.kept[0].severity).toBe(1);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("keeps at least one item and is a no-op under budget", () => {
|
|
83
|
+
const small = [mk("error", "short")];
|
|
84
|
+
expect(capDiagnostics(small, { maxTokens: 10000 })).toMatchObject({
|
|
85
|
+
dropped: 0,
|
|
86
|
+
truncated: false,
|
|
87
|
+
});
|
|
88
|
+
// a single huge item is still kept (never emit empty)
|
|
89
|
+
expect(
|
|
90
|
+
capDiagnostics([mk("error", "q".repeat(9999))], { maxTokens: 5 }).kept,
|
|
91
|
+
).toHaveLength(1);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Edit-triggered auto-diagnostics scheduler (P2 "LSP 自动诊断").
|
|
3
|
+
*
|
|
4
|
+
* `CodeIntelligence.refreshFile` already re-diagnoses one file on demand, and
|
|
5
|
+
* `LSPManager` already quarantines a crash-looping server (sliding-window
|
|
6
|
+
* backoff) — those pieces exist. What's missing is the layer that turns a stream
|
|
7
|
+
* of EDITS into well-paced diagnostics runs and bounds their volume:
|
|
8
|
+
*
|
|
9
|
+
* - DEBOUNCE — coalesce a burst of rapid edits to one file into a single run
|
|
10
|
+
* once edits settle, so typing doesn't spawn a run per keystroke.
|
|
11
|
+
* - THROTTLE — a minimum interval between runs for the same file, so a file
|
|
12
|
+
* edited continuously still can't run more than once per window.
|
|
13
|
+
* - TOKEN CAP — bound the diagnostics handed to the model, dropping the least
|
|
14
|
+
* severe first, with a truncation marker.
|
|
15
|
+
*
|
|
16
|
+
* Pure + clock-injected (no timers): the caller feeds edit events and a `now`,
|
|
17
|
+
* and asks which files are due. Deterministic and unit-testable.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** LSP-ish severity → order (lower = more severe). Accepts numbers or labels. */
|
|
21
|
+
function severityOrder(sev) {
|
|
22
|
+
if (typeof sev === "number" && Number.isFinite(sev)) return sev; // LSP 1..4
|
|
23
|
+
const m = { error: 1, warning: 2, information: 3, info: 3, hint: 4 };
|
|
24
|
+
return m[String(sev || "").toLowerCase()] ?? 5;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export class DiagnosticsScheduler {
|
|
28
|
+
/**
|
|
29
|
+
* @param {object} [opts]
|
|
30
|
+
* debounceMs — quiet period after the last edit before a run (default 300)
|
|
31
|
+
* throttleMs — minimum spacing between runs for one file (default 1000)
|
|
32
|
+
*/
|
|
33
|
+
constructor({ debounceMs = 300, throttleMs = 1000 } = {}) {
|
|
34
|
+
this.debounceMs =
|
|
35
|
+
Number.isFinite(debounceMs) && debounceMs >= 0 ? debounceMs : 300;
|
|
36
|
+
this.throttleMs =
|
|
37
|
+
Number.isFinite(throttleMs) && throttleMs >= 0 ? throttleMs : 1000;
|
|
38
|
+
this._pending = new Map(); // uri → last edit time
|
|
39
|
+
this._lastRun = new Map(); // uri → last run time
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Record an edit to `uri` at `at`. A later edit resets the debounce window. */
|
|
43
|
+
noteEdit(uri, at) {
|
|
44
|
+
if (uri == null) return this;
|
|
45
|
+
this._pending.set(String(uri), Number(at));
|
|
46
|
+
return this;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The URIs whose debounce window has elapsed AND whose throttle interval has
|
|
51
|
+
* passed at `now`. Due URIs are marked run (cleared from pending, `_lastRun`
|
|
52
|
+
* stamped); a throttled URI STAYS pending so it fires once the window passes.
|
|
53
|
+
*/
|
|
54
|
+
due(now) {
|
|
55
|
+
const t = Number(now);
|
|
56
|
+
const out = [];
|
|
57
|
+
for (const [uri, editAt] of this._pending) {
|
|
58
|
+
if (t - editAt < this.debounceMs) continue; // still settling
|
|
59
|
+
const last = this._lastRun.get(uri);
|
|
60
|
+
if (last != null && t - last < this.throttleMs) continue; // throttled
|
|
61
|
+
out.push(uri);
|
|
62
|
+
}
|
|
63
|
+
for (const uri of out) {
|
|
64
|
+
this._pending.delete(uri);
|
|
65
|
+
this._lastRun.set(uri, t);
|
|
66
|
+
}
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** ms until the earliest pending URI could next run, or null if none pending. */
|
|
71
|
+
msUntilNextDue(now) {
|
|
72
|
+
const t = Number(now);
|
|
73
|
+
let min = null;
|
|
74
|
+
for (const [uri, editAt] of this._pending) {
|
|
75
|
+
const debounceReady = editAt + this.debounceMs;
|
|
76
|
+
const last = this._lastRun.get(uri);
|
|
77
|
+
const throttleReady = last != null ? last + this.throttleMs : -Infinity;
|
|
78
|
+
const readyAt = Math.max(debounceReady, throttleReady);
|
|
79
|
+
const ms = Math.max(0, readyAt - t);
|
|
80
|
+
if (min == null || ms < min) min = ms;
|
|
81
|
+
}
|
|
82
|
+
return min;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
pendingCount() {
|
|
86
|
+
return this._pending.size;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
reset() {
|
|
90
|
+
this._pending.clear();
|
|
91
|
+
this._lastRun.clear();
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Bound a diagnostics array to a token budget, dropping the LEAST severe first.
|
|
97
|
+
* The estimate is chars/token over each message (plus a small per-item overhead).
|
|
98
|
+
* Always keeps at least one item when the list is non-empty.
|
|
99
|
+
*
|
|
100
|
+
* @returns {{kept:Array, dropped:number, tokens:number, truncated:boolean}}
|
|
101
|
+
*/
|
|
102
|
+
export function capDiagnostics(
|
|
103
|
+
diagnostics,
|
|
104
|
+
{ maxTokens = 2000, charsPerToken = 4, prioritize = true } = {},
|
|
105
|
+
) {
|
|
106
|
+
const list = Array.isArray(diagnostics) ? [...diagnostics] : [];
|
|
107
|
+
if (prioritize) {
|
|
108
|
+
list.sort(
|
|
109
|
+
(a, b) => severityOrder(a?.severity) - severityOrder(b?.severity),
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
const kept = [];
|
|
113
|
+
let tokens = 0;
|
|
114
|
+
for (const d of list) {
|
|
115
|
+
const cost = Math.max(
|
|
116
|
+
1,
|
|
117
|
+
Math.ceil(
|
|
118
|
+
(String(d?.message || "").length + 24) / Math.max(1, charsPerToken),
|
|
119
|
+
),
|
|
120
|
+
);
|
|
121
|
+
if (kept.length > 0 && tokens + cost > maxTokens) break;
|
|
122
|
+
kept.push(d);
|
|
123
|
+
tokens += cost;
|
|
124
|
+
}
|
|
125
|
+
const dropped = list.length - kept.length;
|
|
126
|
+
return { kept, dropped, tokens, truncated: dropped > 0 };
|
|
127
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Operation fingerprint — binds an approval to the specific operation it
|
|
3
|
+
* authorizes (IDE gap P0#2 "每个副作用关联唯一 approvalId;approvalId 绑定操作
|
|
4
|
+
* 指纹(工具名+参数哈希)").
|
|
5
|
+
*
|
|
6
|
+
* A remote permission ask is matched to its resolve by a random `requestId`.
|
|
7
|
+
* That alone answers "which ask" but not "which OPERATION" — a stale approval
|
|
8
|
+
* card, a replayed resolve, or a request body swapped in flight could settle a
|
|
9
|
+
* gate for an operation the human never actually saw. A fingerprint over the
|
|
10
|
+
* operation's identity (tool + canonicalized arguments) lets the host verify
|
|
11
|
+
* that the decision coming back approves the SAME operation it published, and
|
|
12
|
+
* reject a mismatch (confused-deputy defense) while still failing closed.
|
|
13
|
+
*
|
|
14
|
+
* Pure + deterministic (no clock / RNG): the same operation always yields the
|
|
15
|
+
* same fingerprint, so both ends compute it independently and compare.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { createHash, timingSafeEqual } from "node:crypto";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Recursively canonical JSON: object keys sorted so argument ORDER never
|
|
22
|
+
* changes the fingerprint ({a:1,b:2} === {b:2,a:1}); arrays keep their order
|
|
23
|
+
* (order is semantic for a list). Cycles are impossible here (inputs are the
|
|
24
|
+
* plain ask payload), but a depth guard keeps it total.
|
|
25
|
+
*/
|
|
26
|
+
function canonicalize(value, depth = 0) {
|
|
27
|
+
if (value === null || value === undefined) return "null";
|
|
28
|
+
if (depth > 32) return '"[maxdepth]"';
|
|
29
|
+
const t = typeof value;
|
|
30
|
+
if (t === "number")
|
|
31
|
+
return Number.isFinite(value) ? JSON.stringify(value) : "null";
|
|
32
|
+
if (t === "boolean" || t === "string") return JSON.stringify(value);
|
|
33
|
+
if (t !== "object") return JSON.stringify(String(value)); // bigint/function/symbol → string
|
|
34
|
+
if (Array.isArray(value)) {
|
|
35
|
+
return `[${value.map((v) => canonicalize(v, depth + 1)).join(",")}]`;
|
|
36
|
+
}
|
|
37
|
+
const keys = Object.keys(value).sort();
|
|
38
|
+
return `{${keys.map((k) => `${JSON.stringify(k)}:${canonicalize(value[k], depth + 1)}`).join(",")}}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Stable 32-hex fingerprint of an operation. `args` (structured) is preferred;
|
|
43
|
+
* a `detail` string (e.g. a shell command) is the fallback identity. tool +
|
|
44
|
+
* action + the argument payload together identify the side effect.
|
|
45
|
+
*
|
|
46
|
+
* @param {object} op
|
|
47
|
+
* @param {string} [op.tool] the tool / capability name
|
|
48
|
+
* @param {string} [op.action] a sub-action within the tool
|
|
49
|
+
* @param {*} [op.args] structured arguments (preferred)
|
|
50
|
+
* @param {string} [op.detail] a human/string form used when args is absent
|
|
51
|
+
* @returns {string} 32-char lowercase hex
|
|
52
|
+
*/
|
|
53
|
+
export function operationFingerprint(op = {}) {
|
|
54
|
+
const canonical = canonicalize({
|
|
55
|
+
tool: op.tool ?? null,
|
|
56
|
+
action: op.action ?? null,
|
|
57
|
+
args: op.args !== undefined ? op.args : (op.detail ?? null),
|
|
58
|
+
});
|
|
59
|
+
return createHash("sha256")
|
|
60
|
+
.update(canonical, "utf8")
|
|
61
|
+
.digest("hex")
|
|
62
|
+
.slice(0, 32);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Constant-time equality of two fingerprints. Both must be non-empty strings
|
|
67
|
+
* of equal length; anything else (null / mismatched length / non-string) →
|
|
68
|
+
* false. Constant-time so a mismatch position is not observable.
|
|
69
|
+
*/
|
|
70
|
+
export function fingerprintsMatch(a, b) {
|
|
71
|
+
if (typeof a !== "string" || typeof b !== "string") return false;
|
|
72
|
+
if (a.length === 0 || a.length !== b.length) return false;
|
|
73
|
+
try {
|
|
74
|
+
return timingSafeEqual(Buffer.from(a, "utf8"), Buffer.from(b, "utf8"));
|
|
75
|
+
} catch {
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Verdict for a resolve carrying (or omitting) a fingerprint against the
|
|
82
|
+
* pending ask's fingerprint. Backward compatible: a resolve with NO fingerprint
|
|
83
|
+
* is accepted (old devices predate the field) — absence is tolerated, presence
|
|
84
|
+
* is verified. A PRESENT-but-mismatched fingerprint is rejected.
|
|
85
|
+
*
|
|
86
|
+
* @param {string|null|undefined} pending the pending ask's fingerprint
|
|
87
|
+
* @param {string|null|undefined} resolved the fingerprint the resolve carries
|
|
88
|
+
* @returns {boolean} true = settle the gate; false = reject (keep fail-closed)
|
|
89
|
+
*/
|
|
90
|
+
export function approvalFingerprintOk(pending, resolved) {
|
|
91
|
+
if (resolved === null || resolved === undefined || resolved === "")
|
|
92
|
+
return true;
|
|
93
|
+
if (!pending) return true; // nothing to bind against (legacy pending ask)
|
|
94
|
+
return fingerprintsMatch(pending, resolved);
|
|
95
|
+
}
|
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugin capability declaration + typed options schema — the manifest's
|
|
3
|
+
* declarative SECURITY CONTRACT (P1 "Plugin 能力声明和配置 Schema").
|
|
4
|
+
*
|
|
5
|
+
* Two concerns, both pure (no I/O), both parsed from `plugin.json`:
|
|
6
|
+
*
|
|
7
|
+
* 1. **Declared capabilities** (`manifest.permissions`) — what a plugin is
|
|
8
|
+
* allowed to touch: process spawning, network domains, filesystem roots,
|
|
9
|
+
* MCP, monitors, credentials. On install/upgrade a `diffCapabilities`
|
|
10
|
+
* surfaces what CHANGED; any WIDENING (a new capability, a new domain/root)
|
|
11
|
+
* forces a fresh consent (`consentRequiredForUpgrade`). `auditDeclaredCapabilities`
|
|
12
|
+
* catches the "declares no network but ships an MCP server" mismatch — a
|
|
13
|
+
* plugin whose COMPONENTS need a capability it did not DECLARE.
|
|
14
|
+
*
|
|
15
|
+
* 2. **Typed options schema** (`manifest.optionsSchema`) — each config key's
|
|
16
|
+
* type/default/required/enum/scope/sensitivity. `validateOptions` enforces
|
|
17
|
+
* it AND the invariant that a `sensitive` option can NEVER be supplied from
|
|
18
|
+
* project config (it must come from the user scope / OS keychain), so a
|
|
19
|
+
* checked-in project file can't smuggle a secret. `redactSensitiveOptions`
|
|
20
|
+
* masks secrets for logs/audit.
|
|
21
|
+
*
|
|
22
|
+
* Trust today is exact-version (see [[trust.js]]); this module is what an
|
|
23
|
+
* install/upgrade flow consults to decide whether that trust must be re-taken.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
// ─── capabilities ───────────────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
export const CAPABILITY_KINDS = Object.freeze([
|
|
29
|
+
"process",
|
|
30
|
+
"network",
|
|
31
|
+
"filesystem",
|
|
32
|
+
"mcp",
|
|
33
|
+
"monitor",
|
|
34
|
+
"credential",
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
function toBool(v) {
|
|
38
|
+
if (v === true) return true;
|
|
39
|
+
if (typeof v === "number") return v === 1;
|
|
40
|
+
if (typeof v === "string") {
|
|
41
|
+
const s = v.trim().toLowerCase();
|
|
42
|
+
return s === "true" || s === "1" || s === "yes";
|
|
43
|
+
}
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Normalize a string list (array | comma-string | null) → deduped string[]. */
|
|
48
|
+
function toList(v) {
|
|
49
|
+
if (v == null) return [];
|
|
50
|
+
const list = Array.isArray(v) ? v : String(v).split(/[,\s]+/);
|
|
51
|
+
const out = [];
|
|
52
|
+
const seen = new Set();
|
|
53
|
+
for (const item of list) {
|
|
54
|
+
const s = String(item).trim();
|
|
55
|
+
if (!s || seen.has(s)) continue;
|
|
56
|
+
seen.add(s);
|
|
57
|
+
out.push(s);
|
|
58
|
+
}
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** A `network` declaration → `{ any, domains }`. `true`/`"*"` = any host. */
|
|
63
|
+
function normalizeNetwork(v) {
|
|
64
|
+
if (v === true || v === "*") return { any: true, domains: [] };
|
|
65
|
+
if (Array.isArray(v) || typeof v === "string") {
|
|
66
|
+
const domains = toList(v);
|
|
67
|
+
return {
|
|
68
|
+
any: domains.includes("*"),
|
|
69
|
+
domains: domains.filter((d) => d !== "*"),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
if (v && typeof v === "object") {
|
|
73
|
+
const domains = toList(v.domains);
|
|
74
|
+
return {
|
|
75
|
+
any: toBool(v.any) || domains.includes("*"),
|
|
76
|
+
domains: domains.filter((d) => d !== "*"),
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
return { any: false, domains: [] };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Normalize a manifest `permissions` block into the canonical capability set.
|
|
84
|
+
* Everything defaults to DENY (false / empty) — a capability must be declared.
|
|
85
|
+
*/
|
|
86
|
+
export function normalizeCapabilities(raw) {
|
|
87
|
+
const r = raw && typeof raw === "object" && !Array.isArray(raw) ? raw : {};
|
|
88
|
+
return {
|
|
89
|
+
process: toBool(r.process),
|
|
90
|
+
network: normalizeNetwork(r.network),
|
|
91
|
+
filesystem: { roots: toList(r.filesystem ?? r.filesystemRoots) },
|
|
92
|
+
mcp: toBool(r.mcp),
|
|
93
|
+
monitor: toBool(r.monitor ?? r.monitors),
|
|
94
|
+
credential: { names: toList(r.credential ?? r.credentials) },
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const EMPTY_CAPS = normalizeCapabilities(null);
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* What `next` grants that `prev` did not (and vice-versa). Each entry is a
|
|
102
|
+
* stable string token (e.g. "process", "network:*", "network:api.x.com",
|
|
103
|
+
* "filesystem:/tmp", "credential:GH_TOKEN"). `widened` = any new grant.
|
|
104
|
+
*/
|
|
105
|
+
export function diffCapabilities(prev, next) {
|
|
106
|
+
const a = prev || EMPTY_CAPS;
|
|
107
|
+
const b = next || EMPTY_CAPS;
|
|
108
|
+
const added = [];
|
|
109
|
+
const removed = [];
|
|
110
|
+
|
|
111
|
+
const boolCap = (key) => {
|
|
112
|
+
if (b[key] && !a[key]) added.push(key);
|
|
113
|
+
else if (!b[key] && a[key]) removed.push(key);
|
|
114
|
+
};
|
|
115
|
+
boolCap("process");
|
|
116
|
+
boolCap("mcp");
|
|
117
|
+
boolCap("monitor");
|
|
118
|
+
|
|
119
|
+
// network
|
|
120
|
+
if (b.network.any && !a.network.any) added.push("network:*");
|
|
121
|
+
if (!b.network.any && a.network.any) removed.push("network:*");
|
|
122
|
+
if (!a.network.any) {
|
|
123
|
+
for (const d of b.network.domains) {
|
|
124
|
+
if (!a.network.domains.includes(d)) added.push(`network:${d}`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
for (const d of a.network.domains) {
|
|
128
|
+
if (!b.network.any && !b.network.domains.includes(d))
|
|
129
|
+
removed.push(`network:${d}`);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const listCap = (key, prefix, pick) => {
|
|
133
|
+
const av = pick(a);
|
|
134
|
+
const bv = pick(b);
|
|
135
|
+
for (const x of bv) if (!av.includes(x)) added.push(`${prefix}:${x}`);
|
|
136
|
+
for (const x of av) if (!bv.includes(x)) removed.push(`${prefix}:${x}`);
|
|
137
|
+
};
|
|
138
|
+
listCap("filesystem", "filesystem", (c) => c.filesystem.roots);
|
|
139
|
+
listCap("credential", "credential", (c) => c.credential.names);
|
|
140
|
+
|
|
141
|
+
return { added, removed, widened: added.length > 0 };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Consent must be re-taken on upgrade when the capability set WIDENS (a new
|
|
146
|
+
* grant appears). A pure re-install from empty (first install) therefore always
|
|
147
|
+
* requires consent for whatever it declares.
|
|
148
|
+
*/
|
|
149
|
+
export function consentRequiredForUpgrade({
|
|
150
|
+
prevCaps = null,
|
|
151
|
+
nextCaps = null,
|
|
152
|
+
} = {}) {
|
|
153
|
+
return diffCapabilities(prevCaps, nextCaps).widened;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Human-readable lines for an install/upgrade capability prompt. */
|
|
157
|
+
export function describeCapabilities(caps) {
|
|
158
|
+
const c = caps || EMPTY_CAPS;
|
|
159
|
+
const lines = [];
|
|
160
|
+
if (c.process)
|
|
161
|
+
lines.push("process: may spawn processes / run bundled binaries & hooks");
|
|
162
|
+
if (c.network.any) lines.push("network: ANY host");
|
|
163
|
+
else if (c.network.domains.length)
|
|
164
|
+
lines.push(`network: ${c.network.domains.join(", ")}`);
|
|
165
|
+
if (c.filesystem.roots.length)
|
|
166
|
+
lines.push(`filesystem: ${c.filesystem.roots.join(", ")}`);
|
|
167
|
+
if (c.mcp) lines.push("mcp: may run MCP servers");
|
|
168
|
+
if (c.monitor) lines.push("monitor: may run background monitors");
|
|
169
|
+
if (c.credential.names.length)
|
|
170
|
+
lines.push(`credential: ${c.credential.names.join(", ")}`);
|
|
171
|
+
return lines;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Catch a manifest whose COMPONENTS need a capability it did not DECLARE — e.g.
|
|
176
|
+
* ships an MCP server but declared no `mcp`/`network`, or ships bin/hooks/lsp
|
|
177
|
+
* (which spawn processes) without `process`. Best-effort static check; returns
|
|
178
|
+
* `[{capability, reason}]`. Callers run it only when a `permissions` block is
|
|
179
|
+
* present (an undeclared legacy plugin is unrestricted by design).
|
|
180
|
+
*/
|
|
181
|
+
export function auditDeclaredCapabilities(manifest) {
|
|
182
|
+
const caps = manifest?.capabilities || EMPTY_CAPS;
|
|
183
|
+
const comp = manifest?.components || {};
|
|
184
|
+
const findings = [];
|
|
185
|
+
const has = (v) =>
|
|
186
|
+
Array.isArray(v)
|
|
187
|
+
? v.length > 0
|
|
188
|
+
: Boolean(v && (v.count || v.file || v.inline));
|
|
189
|
+
|
|
190
|
+
const spawns =
|
|
191
|
+
has(comp.bin) ||
|
|
192
|
+
has(comp.hooks) ||
|
|
193
|
+
(Array.isArray(comp.lsp) && comp.lsp.length > 0);
|
|
194
|
+
if (spawns && !caps.process) {
|
|
195
|
+
findings.push({
|
|
196
|
+
capability: "process",
|
|
197
|
+
reason:
|
|
198
|
+
"ships bin/hooks/lsp that spawn processes but did not declare the 'process' capability",
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
if (has(comp.mcp) && !caps.mcp) {
|
|
202
|
+
findings.push({
|
|
203
|
+
capability: "mcp",
|
|
204
|
+
reason: "ships an MCP server but did not declare the 'mcp' capability",
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
if (has(comp.mcp) && !caps.network.any && caps.network.domains.length === 0) {
|
|
208
|
+
findings.push({
|
|
209
|
+
capability: "network",
|
|
210
|
+
reason:
|
|
211
|
+
"ships an MCP server (which typically needs network) but declared no network domains",
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
if (has(comp.monitors) && !caps.monitor) {
|
|
215
|
+
findings.push({
|
|
216
|
+
capability: "monitor",
|
|
217
|
+
reason: "ships monitors but did not declare the 'monitor' capability",
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
return findings;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ─── options schema ─────────────────────────────────────────────────────────
|
|
224
|
+
|
|
225
|
+
export const OPTION_TYPES = Object.freeze([
|
|
226
|
+
"string",
|
|
227
|
+
"number",
|
|
228
|
+
"boolean",
|
|
229
|
+
"enum",
|
|
230
|
+
"string[]",
|
|
231
|
+
]);
|
|
232
|
+
export const OPTION_SCOPES = Object.freeze(["user", "project", "both"]);
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Normalize a manifest `optionsSchema` (key → descriptor) into a canonical map.
|
|
236
|
+
* Unknown types default to "string"; `scope` defaults to "both"; `sensitive`
|
|
237
|
+
* defaults false. A sensitive option is never allowed at project scope, so its
|
|
238
|
+
* effective scope is narrowed to "user" here (validateOptions also enforces it).
|
|
239
|
+
*/
|
|
240
|
+
export function normalizeOptionsSchema(raw) {
|
|
241
|
+
const r = raw && typeof raw === "object" && !Array.isArray(raw) ? raw : {};
|
|
242
|
+
const out = {};
|
|
243
|
+
for (const [key, descRaw] of Object.entries(r)) {
|
|
244
|
+
const d = descRaw && typeof descRaw === "object" ? descRaw : {};
|
|
245
|
+
const type = OPTION_TYPES.includes(d.type) ? d.type : "string";
|
|
246
|
+
const sensitive = toBool(d.sensitive);
|
|
247
|
+
let scope = OPTION_SCOPES.includes(d.scope) ? d.scope : "both";
|
|
248
|
+
if (sensitive && scope !== "user") scope = "user"; // secrets: user scope only
|
|
249
|
+
out[key] = {
|
|
250
|
+
type,
|
|
251
|
+
default: d.default === undefined ? null : d.default,
|
|
252
|
+
required: toBool(d.required),
|
|
253
|
+
enum:
|
|
254
|
+
type === "enum" && Array.isArray(d.enum) ? d.enum.map(String) : null,
|
|
255
|
+
scope,
|
|
256
|
+
sensitive,
|
|
257
|
+
description: typeof d.description === "string" ? d.description : "",
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
return out;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** The default value object for a schema (keys with a non-null default). */
|
|
264
|
+
export function optionDefaults(schema) {
|
|
265
|
+
const out = {};
|
|
266
|
+
for (const [key, d] of Object.entries(schema || {})) {
|
|
267
|
+
if (d.default !== null && d.default !== undefined) out[key] = d.default;
|
|
268
|
+
}
|
|
269
|
+
return out;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function coerce(type, value, enumVals) {
|
|
273
|
+
switch (type) {
|
|
274
|
+
case "number": {
|
|
275
|
+
const n = Number(value);
|
|
276
|
+
return Number.isFinite(n) ? { ok: true, value: n } : { ok: false };
|
|
277
|
+
}
|
|
278
|
+
case "boolean": {
|
|
279
|
+
if (typeof value === "boolean") return { ok: true, value };
|
|
280
|
+
const s = String(value).trim().toLowerCase();
|
|
281
|
+
if (["true", "1", "yes"].includes(s)) return { ok: true, value: true };
|
|
282
|
+
if (["false", "0", "no"].includes(s)) return { ok: true, value: false };
|
|
283
|
+
return { ok: false };
|
|
284
|
+
}
|
|
285
|
+
case "enum":
|
|
286
|
+
return enumVals && enumVals.includes(String(value))
|
|
287
|
+
? { ok: true, value: String(value) }
|
|
288
|
+
: { ok: false };
|
|
289
|
+
case "string[]": {
|
|
290
|
+
if (Array.isArray(value)) return { ok: true, value: value.map(String) };
|
|
291
|
+
if (typeof value === "string") return { ok: true, value: toList(value) };
|
|
292
|
+
return { ok: false };
|
|
293
|
+
}
|
|
294
|
+
default:
|
|
295
|
+
return { ok: true, value: String(value) };
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Validate a values object against an options schema at a given scope. Enforces
|
|
301
|
+
* types, `required`, `enum`, per-option `scope`, and the SENSITIVE-not-from-
|
|
302
|
+
* project invariant. Returns the merged/coerced values plus errors/warnings —
|
|
303
|
+
* never throws.
|
|
304
|
+
*
|
|
305
|
+
* @param {object} schema from normalizeOptionsSchema
|
|
306
|
+
* @param {object} values the config values to check
|
|
307
|
+
* @param {object} [opts] { scope: 'user'|'project' }
|
|
308
|
+
* @returns {{ok:boolean, errors:string[], warnings:string[], normalized:object}}
|
|
309
|
+
*/
|
|
310
|
+
export function validateOptions(schema, values, { scope = "user" } = {}) {
|
|
311
|
+
const s = schema || {};
|
|
312
|
+
const v = values && typeof values === "object" ? values : {};
|
|
313
|
+
const errors = [];
|
|
314
|
+
const warnings = [];
|
|
315
|
+
const normalized = optionDefaults(s);
|
|
316
|
+
|
|
317
|
+
for (const [key, d] of Object.entries(s)) {
|
|
318
|
+
const provided = Object.prototype.hasOwnProperty.call(v, key);
|
|
319
|
+
if (!provided) {
|
|
320
|
+
if (d.required && normalized[key] === undefined) {
|
|
321
|
+
errors.push(`missing required option "${key}"`);
|
|
322
|
+
}
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
// sensitive secrets can never come from a project-scoped config file.
|
|
326
|
+
if (d.sensitive && scope === "project") {
|
|
327
|
+
errors.push(
|
|
328
|
+
`option "${key}" is sensitive and cannot be set from project config — use user scope / OS keychain`,
|
|
329
|
+
);
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
// a user-only option may not be set at project scope.
|
|
333
|
+
if (d.scope === "user" && scope === "project") {
|
|
334
|
+
errors.push(
|
|
335
|
+
`option "${key}" is user-scoped and cannot be set from project config`,
|
|
336
|
+
);
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
if (d.scope === "project" && scope === "user") {
|
|
340
|
+
warnings.push(
|
|
341
|
+
`option "${key}" is project-scoped; a user value may be ignored`,
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
const c = coerce(d.type, v[key], d.enum);
|
|
345
|
+
if (!c.ok) {
|
|
346
|
+
errors.push(
|
|
347
|
+
`option "${key}" must be ${d.type}${d.enum ? ` (${d.enum.join("|")})` : ""}`,
|
|
348
|
+
);
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
normalized[key] = c.value;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
for (const key of Object.keys(v)) {
|
|
355
|
+
if (!Object.prototype.hasOwnProperty.call(s, key)) {
|
|
356
|
+
warnings.push(`unknown option "${key}" (not in optionsSchema)`);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
return { ok: errors.length === 0, errors, warnings, normalized };
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** Clone `values` with every schema-declared sensitive key masked. */
|
|
364
|
+
export function redactSensitiveOptions(schema, values) {
|
|
365
|
+
const s = schema || {};
|
|
366
|
+
const out = {};
|
|
367
|
+
for (const [key, val] of Object.entries(values || {})) {
|
|
368
|
+
out[key] = s[key]?.sensitive ? "***" : val;
|
|
369
|
+
}
|
|
370
|
+
return out;
|
|
371
|
+
}
|