agentengine-sdk-python 0.8.2__py3-none-any.whl → 0.8.3__py3-none-any.whl
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.
- {agentengine_sdk_python-0.8.2.dist-info → agentengine_sdk_python-0.8.3.dist-info}/METADATA +18 -25
- {agentengine_sdk_python-0.8.2.dist-info → agentengine_sdk_python-0.8.3.dist-info}/RECORD +224 -169
- ksadk/_build_provenance.json +1 -0
- ksadk/agui/_agent_helpers.py +4 -0
- ksadk/api/client.py +3 -0
- ksadk/builders/managed_runtime_builder.py +3 -0
- ksadk/cli/__init__.py +6 -0
- ksadk/cli/cmd_plugin.py +657 -0
- ksadk/codex/runtime.py +50 -8
- ksadk/configs/env_registry.py +31 -3
- ksadk/configs/settings.py +9 -0
- ksadk/context_engine/contributors.py +73 -8
- ksadk/conversations/__init__.py +19 -0
- ksadk/conversations/context.py +104 -24
- ksadk/conversations/contracts.py +266 -0
- ksadk/conversations/message_projection.py +19 -1
- ksadk/conversations/projector.py +616 -0
- ksadk/conversations/reducer.py +98 -0
- ksadk/events/_v1_compat/projection.py +2 -0
- ksadk/events/adapters/codex.py +190 -0
- ksadk/harness/__init__.py +45 -35
- ksadk/harness/runtime.py +60 -1
- ksadk/kernel/contract_fingerprints.py +5 -4
- ksadk/kernel/contracts.py +8 -0
- ksadk/kernel/control.py +1 -0
- ksadk/kernel/ingress.py +21 -0
- ksadk/kernel/memory_store.py +1 -0
- ksadk/kernel/postgres_store.py +1 -0
- ksadk/kernel/sqlite_store.py +1 -0
- ksadk/kernel/worker.py +11 -0
- ksadk/memory/providers/local_sqlite.py +7 -0
- ksadk/plugins/__init__.py +167 -0
- ksadk/plugins/bridges/__init__.py +45 -0
- ksadk/plugins/bridges/codex.py +552 -0
- ksadk/plugins/bridges/dsh.py +1116 -0
- ksadk/plugins/builtins.py +747 -0
- ksadk/plugins/bundle.py +246 -0
- ksadk/plugins/bundle_security.py +167 -0
- ksadk/plugins/composition.py +529 -0
- ksadk/plugins/context_contributor.py +437 -0
- ksadk/plugins/contracts.py +451 -0
- ksadk/plugins/dsh_toolchain.py +935 -0
- ksadk/plugins/ecosystem_bridge.py +1105 -0
- ksadk/plugins/ecosystem_probe.py +191 -0
- ksadk/plugins/host.py +724 -0
- ksadk/plugins/providers/__init__.py +85 -0
- ksadk/plugins/providers/bundles/ksadk-codex/cordis.patch.yml +3 -0
- ksadk/plugins/providers/bundles/ksadk-codex/index.mjs +18 -0
- ksadk/plugins/providers/bundles/ksadk-codex/package.json +19 -0
- ksadk/plugins/providers/bundles/ksadk-harness/cordis.patch.yml +3 -0
- ksadk/plugins/providers/bundles/ksadk-harness/index.mjs +19 -0
- ksadk/plugins/providers/bundles/ksadk-harness/package.json +19 -0
- ksadk/plugins/providers/codex.py +722 -0
- ksadk/plugins/providers/codex_dsh.py +83 -0
- ksadk/plugins/providers/dsh.py +951 -0
- ksadk/plugins/providers/dsh_descriptor_host.py +226 -0
- ksadk/plugins/providers/harness.py +627 -0
- ksadk/plugins/providers/harness_dsh.py +91 -0
- ksadk/plugins/providers/legacy.py +215 -0
- ksadk/plugins/providers/legacy_catalog.py +100 -0
- ksadk/plugins/providers/shipped_dsh.py +370 -0
- ksadk/plugins/resolver.py +331 -0
- ksadk/plugins/subagent_providers/__init__.py +12 -0
- ksadk/plugins/subagent_providers/codex.py +443 -0
- ksadk/plugins/subagents.py +333 -0
- ksadk/runtime/_runner_adapter/stream_mapping.py +51 -42
- ksadk/runtime/adapter.py +1 -0
- ksadk/runtime/factory.py +76 -2
- ksadk/runtime/framework_adapters.py +18 -0
- ksadk/runtime/runner_adapter.py +5 -0
- ksadk/sandbox/__init__.py +2 -1
- ksadk/sandbox/factory.py +72 -0
- ksadk/scheduler/__init__.py +18 -0
- ksadk/scheduler/calendar.py +102 -0
- ksadk/scheduler/contracts.py +254 -0
- ksadk/scheduler/dispatcher.py +129 -0
- ksadk/scheduler/engine.py +416 -0
- ksadk/scheduler/sqlite_store.py +563 -0
- ksadk/server/static/assets/{ArtifactsPanel-DSU1sWD_.js → ArtifactsPanel-10GPXIJ6.js} +1 -1
- ksadk/server/static/assets/{CodeBlock-DOH6MVcn.js → CodeBlock-BXk1l-G1.js} +1 -1
- ksadk/server/static/assets/{MathMessageMarkdown-BL8my1R2.js → MathMessageMarkdown-D9uB55Oh.js} +1 -1
- ksadk/server/static/assets/{MermaidBlock-Dz4IP-Tx.js → MermaidBlock--OEYoXIJ.js} +6 -6
- ksadk/server/static/assets/{NativeTerminalPanel-DUrK0JpZ.js → NativeTerminalPanel-DrEe1syq.js} +1 -1
- ksadk/server/static/assets/{abnfDiagram-VCTEODGH-g20pFzNV.js → abnfDiagram-VCTEODGH-eeTFJLvo.js} +1 -1
- ksadk/server/static/assets/{arc-C4FzinUA.js → arc-S3ZNbIAd.js} +1 -1
- ksadk/server/static/assets/architecture-7GRP2DOG-Dk9nRQ6-.js +1 -0
- ksadk/server/static/assets/{architectureDiagram-5GKGNRK7-DpIqL5h4.js → architectureDiagram-5GKGNRK7-CRjgEJDX.js} +1 -1
- ksadk/server/static/assets/{blockDiagram-NRAW4CY4-BjkljTNz.js → blockDiagram-NRAW4CY4-C7bsBoNH.js} +1 -1
- ksadk/server/static/assets/{c4Diagram-UCG6FXSJ-Dk_ieq2X.js → c4Diagram-UCG6FXSJ-BX3c33Ob.js} +1 -1
- ksadk/server/static/assets/channel-s354Yo6o.js +1 -0
- ksadk/server/static/assets/chunk-2Q5K7J3B-CDpPpKR5.js +1 -0
- ksadk/server/static/assets/{chunk-5VM5RSS4-BVHAchFb.js → chunk-5VM5RSS4-bZ7ws-8m.js} +1 -1
- ksadk/server/static/assets/{chunk-F27PBJKO-C-ipQzuS.js → chunk-F27PBJKO-D2r0pvhY.js} +1 -1
- ksadk/server/static/assets/{chunk-G27WJ6UU-C2EyJbIK.js → chunk-G27WJ6UU-CZwF3oUP.js} +1 -1
- ksadk/server/static/assets/{chunk-JWPE2WC7-vYvVJb_M.js → chunk-JWPE2WC7-DigFYCML.js} +1 -1
- ksadk/server/static/assets/{chunk-LCL6LL3I-BcI6ysPT.js → chunk-LCL6LL3I-CnorGV5q.js} +1 -1
- ksadk/server/static/assets/{chunk-POPQ4Y6H-C030x_Z1.js → chunk-POPQ4Y6H-CexntQA-.js} +1 -1
- ksadk/server/static/assets/{chunk-RHFEMEQ7-yyGpsz4n.js → chunk-RHFEMEQ7-CHTC7UxA.js} +1 -1
- ksadk/server/static/assets/{chunk-SVP7TREG-BLTlmMU7.js → chunk-SVP7TREG-BC1EWt_-.js} +1 -1
- ksadk/server/static/assets/{chunk-XXDRQBXY-C_32ArgP.js → chunk-XXDRQBXY-BTG24xN0.js} +1 -1
- ksadk/server/static/assets/classDiagram-DTDB5LWJ-Dd0nucl9.js +1 -0
- ksadk/server/static/assets/classDiagram-v2-JRS7N3AN-Dd0nucl9.js +1 -0
- ksadk/server/static/assets/{cose-bilkent-JH36ORCC-9YQYwdl0.js → cose-bilkent-JH36ORCC-CkonRdAS.js} +1 -1
- ksadk/server/static/assets/cynefin-OW5HDTMX-BBlT4uMf.js +1 -0
- ksadk/server/static/assets/{cynefinDiagram-5FMLGOSQ-ErLF5N13.js → cynefinDiagram-5FMLGOSQ-DEOCBe15.js} +1 -1
- ksadk/server/static/assets/{dagre-3AP2YEHR-DFiQF-6f.js → dagre-3AP2YEHR-DBThPuLa.js} +1 -1
- ksadk/server/static/assets/{dagre-BuhGnRI1.js → dagre-ClTPrTht.js} +1 -1
- ksadk/server/static/assets/{diagram-S7CK7UJ4-Bt1v8-GC.js → diagram-S7CK7UJ4-DsZHrnpJ.js} +1 -1
- ksadk/server/static/assets/{diagram-UQ7AKVKN-DSCxJdBK.js → diagram-UQ7AKVKN-BJR4nhVr.js} +1 -1
- ksadk/server/static/assets/{diagram-VSXAHHWV-DHfYwp_9.js → diagram-VSXAHHWV-xzcERX0a.js} +1 -1
- ksadk/server/static/assets/{diagram-VX7I27RA-DdzD-4Le.js → diagram-VX7I27RA-tn2xM1xu.js} +1 -1
- ksadk/server/static/assets/{diagram-Z3DM3KII-Du-nAJ9F.js → diagram-Z3DM3KII-BZPGBkw5.js} +1 -1
- ksadk/server/static/assets/{dist-ClxGviKw.js → dist-4zN1_tq5.js} +1 -1
- ksadk/server/static/assets/{dist-BE7bu-DL.js → dist-8VWI-2bZ.js} +1 -1
- ksadk/server/static/assets/{dist-BX8z72IC.js → dist-B-46q3Hk.js} +1 -1
- ksadk/server/static/assets/{dist-BjU6y-A2.js → dist-B0gdk4rl.js} +1 -1
- ksadk/server/static/assets/{dist-C_wsv-Qd.js → dist-B1oWRmrH.js} +1 -1
- ksadk/server/static/assets/{dist-ngtN0DJB.js → dist-BI1qQH4n.js} +1 -1
- ksadk/server/static/assets/{dist-dydu68Fl.js → dist-BJLUwH6W.js} +1 -1
- ksadk/server/static/assets/{dist-Dq9gLD7L.js → dist-Bbwqa8TG.js} +1 -1
- ksadk/server/static/assets/{dist-CttYUqzS.js → dist-Btyo4vX4.js} +1 -1
- ksadk/server/static/assets/{dist-CXYYBw3_.js → dist-C71CsujO.js} +1 -1
- ksadk/server/static/assets/{dist-dDdADKFA.js → dist-CXpH_Rdd.js} +1 -1
- ksadk/server/static/assets/{dist-CSh_DE14.js → dist-Cq4OL6nO.js} +1 -1
- ksadk/server/static/assets/{dist-8hqcx0sU.js → dist-D1V3f1B4.js} +1 -1
- ksadk/server/static/assets/{dist-BVDffZ0U.js → dist-DJmCPRVB.js} +1 -1
- ksadk/server/static/assets/{dist-DnfXs8Vn.js → dist-DQAB0qn_.js} +1 -1
- ksadk/server/static/assets/{dist-DNp6rLRA.js → dist-DUwKa_l6.js} +1 -1
- ksadk/server/static/assets/{dist-B7seoj3d.js → dist-DdRBIceA.js} +1 -1
- ksadk/server/static/assets/{dist-BytlxIDT.js → dist-DfGFrb0v.js} +1 -1
- ksadk/server/static/assets/{dist-Bf2M8m3N.js → dist-DjhwGUgZ.js} +1 -1
- ksadk/server/static/assets/{dist-W-TIVntx.js → dist-DyqV4fL4.js} +1 -1
- ksadk/server/static/assets/{dist-DUX-id_F.js → dist-M67Hwu0n.js} +1 -1
- ksadk/server/static/assets/{dist-DmldrHd_.js → dist-u8tVH5Ak.js} +1 -1
- ksadk/server/static/assets/{ebnfDiagram-PWID7BFC-Da9C9NO1.js → ebnfDiagram-PWID7BFC-DVfMsLge.js} +1 -1
- ksadk/server/static/assets/{erDiagram-SSCWMZ5O-EAGqqR79.js → erDiagram-SSCWMZ5O-CWEZSYaw.js} +1 -1
- ksadk/server/static/assets/eventmodeling-NTZA5JFV-Dr5okd8V.js +1 -0
- ksadk/server/static/assets/flowDiagram-A5DVABFB-VEO-cVcO.js +1 -0
- ksadk/server/static/assets/{ganttDiagram-EL5Y4UJY-DqJsKb59.js → ganttDiagram-EL5Y4UJY-TWIxoyYL.js} +1 -1
- ksadk/server/static/assets/gitGraph-4MIJSDKK-CKiKY9Cf.js +1 -0
- ksadk/server/static/assets/{gitGraphDiagram-WWUBYQGX-B6g4dtDi.js → gitGraphDiagram-WWUBYQGX-DDh2hd2S.js} +1 -1
- ksadk/server/static/assets/index-B2k_urY8.js +241 -0
- ksadk/server/static/assets/index-ILPt6IWF.css +2 -0
- ksadk/server/static/assets/info-A6RAGUB7-ubA4Nm_p.js +1 -0
- ksadk/server/static/assets/{infoDiagram-RXCK75RN-Djm_nbd5.js → infoDiagram-RXCK75RN-CgoyWiDR.js} +1 -1
- ksadk/server/static/assets/{ishikawaDiagram-5VMMS53U-DWya9SG2.js → ishikawaDiagram-5VMMS53U-CyRIu_om.js} +1 -1
- ksadk/server/static/assets/{journeyDiagram-EYS64GPL-DutxhSNI.js → journeyDiagram-EYS64GPL-DN5JXHzN.js} +1 -1
- ksadk/server/static/assets/{kanban-definition-3QL26DDD-DJdOF8Fm.js → kanban-definition-3QL26DDD-CNP3ui66.js} +1 -1
- ksadk/server/static/assets/{katex-vFWytM5c.js → katex-DWEzQy1d.js} +1 -1
- ksadk/server/static/assets/{linear-BHjG8b-J.js → linear-C-qHp7d2.js} +1 -1
- ksadk/server/static/assets/{mermaid-parser.core-KGSy4jWT.js → mermaid-parser.core-Cl-K943T.js} +2 -2
- ksadk/server/static/assets/{mindmap-definition-FBJOCRG2-9-HC88D4.js → mindmap-definition-FBJOCRG2-D5DB5mYm.js} +1 -1
- ksadk/server/static/assets/packet-AYTQ26CC-C7R3NUlK.js +1 -0
- ksadk/server/static/assets/{pegDiagram-XKGWAZYB-DrEZd4fD.js → pegDiagram-XKGWAZYB-BQVc9ia_.js} +1 -1
- ksadk/server/static/assets/pie-WAS4IAKB-BIT6qjr3.js +1 -0
- ksadk/server/static/assets/{pieDiagram-E7YTZNPT-DpjqOFFp.js → pieDiagram-E7YTZNPT-B6UCAvtY.js} +1 -1
- ksadk/server/static/assets/{quadrantDiagram-AXDQQJYC-BiNhgOXX.js → quadrantDiagram-AXDQQJYC-mIr2d3eb.js} +1 -1
- ksadk/server/static/assets/radar-RG4KPBEZ-l5noef9X.js +1 -0
- ksadk/server/static/assets/railroad-74A4TZTK-Baea6ZvM.js +1 -0
- ksadk/server/static/assets/railroad-abnf-HS5TGJTU-OKn8zZnU.js +1 -0
- ksadk/server/static/assets/railroad-ebnf-LZEXJU2U-DdKAKC4l.js +1 -0
- ksadk/server/static/assets/railroad-peg-WCYAUIDC-Bds8udlG.js +1 -0
- ksadk/server/static/assets/{railroadDiagram-O6MQD6OU-_Y3Ojg6G.js → railroadDiagram-O6MQD6OU-Dt3FFDmK.js} +1 -1
- ksadk/server/static/assets/{requirementDiagram-EFPCY7ZU-DeCFdg9K.js → requirementDiagram-EFPCY7ZU-Cz_vQtgF.js} +1 -1
- ksadk/server/static/assets/{sankeyDiagram-P5KCCOFB-CPH75rhw.js → sankeyDiagram-P5KCCOFB-wlO1TsH5.js} +1 -1
- ksadk/server/static/assets/{sequenceDiagram-WJ2MYXX4-OEpQQdr4.js → sequenceDiagram-WJ2MYXX4-CHRbElYq.js} +1 -1
- ksadk/server/static/assets/{sizeCapture-X5ZJPWSS-heUErzhI.js → sizeCapture-X5ZJPWSS-DXX54iyO.js} +1 -1
- ksadk/server/static/assets/{stateDiagram-HBIQ2CUA-DvA3jSMB.js → stateDiagram-HBIQ2CUA-Dp_lw9II.js} +1 -1
- ksadk/server/static/assets/stateDiagram-v2-4QOOHH4V-5b-8o2iS.js +1 -0
- ksadk/server/static/assets/{swimlanes-XN3QIQJK-CR-dfN_t.js → swimlanes-XN3QIQJK-D8O5UlLG.js} +1 -1
- ksadk/server/static/assets/swimlanesDiagram-VK2B7HYN-uf6WPRA4.js +8 -0
- ksadk/server/static/assets/{timeline-definition-24CTP7MA-DAVJf1TX.js → timeline-definition-24CTP7MA-8mfKcxvG.js} +1 -1
- ksadk/server/static/assets/treeView-Q6P3EWNA-CK5CSrSO.js +1 -0
- ksadk/server/static/assets/treemap-WGGIJYW6-Ch2BKgAM.js +1 -0
- ksadk/server/static/assets/{vennDiagram-4TSXK5OY-DwM8eDkh.js → vennDiagram-4TSXK5OY-EnNl0DE7.js} +1 -1
- ksadk/server/static/assets/wardley-WFR3VGLG-YW3Q0ml_.js +1 -0
- ksadk/server/static/assets/{wardleyDiagram-VM6X3IG4-DZL-Zz2t.js → wardleyDiagram-VM6X3IG4-DBu5fbYr.js} +1 -1
- ksadk/server/static/assets/{xychartDiagram-S5SC5T6Z-Dy6Yh_hu.js → xychartDiagram-S5SC5T6Z-BSVXs4IY.js} +1 -1
- ksadk/server/static/index.html +2 -2
- ksadk/skills/service_client.py +48 -0
- ksadk/studio/agent_lifecycle.py +26 -20
- ksadk/studio/api.py +513 -71
- ksadk/studio/api_catalog_routes.py +5 -0
- ksadk/studio/api_contracts.py +34 -1
- ksadk/studio/api_plugin_routes.py +480 -0
- ksadk/studio/attachment_store.py +187 -0
- ksadk/studio/authoring.py +10 -4
- ksadk/studio/authoring_coordinator.py +12 -2
- ksadk/studio/builder.py +123 -43
- ksadk/studio/cloud.py +4 -0
- ksadk/studio/codex_agent_service.py +12 -1
- ksadk/studio/codex_builder.py +54 -15
- ksadk/studio/codex_manifest.py +21 -1
- ksadk/studio/codex_run.py +16 -4
- ksadk/studio/compatibility_report.py +445 -0
- ksadk/studio/compiler.py +6 -1
- ksadk/studio/contracts.py +123 -4
- ksadk/studio/dsh_provider_registration.py +768 -0
- ksadk/studio/event_store.py +54 -6
- ksadk/studio/framework_run.py +37 -4
- ksadk/studio/hosted_kernel.py +1 -1
- ksadk/studio/operations.py +12 -2
- ksadk/studio/plugin_composition.py +301 -0
- ksadk/studio/plugin_kernel_adapter.py +128 -0
- ksadk/studio/plugin_runtime.py +652 -0
- ksadk/studio/resource_catalog.py +6 -1
- ksadk/studio/run_service.py +713 -75
- ksadk/studio/runtime_source.py +9 -1
- ksadk/studio/scheduler_runtime.py +374 -0
- ksadk/studio/scheduler_service.py +211 -0
- ksadk/studio/service.py +672 -82
- ksadk/studio/shared_web.py +167 -28
- ksadk/studio/soul.py +52 -0
- ksadk/studio/static/assets/index-DXs1Eq4M.css +1 -0
- ksadk/studio/static/assets/index-DeDqwTsD.js +283 -0
- ksadk/studio/static/index.html +2 -2
- ksadk/studio/validator.py +1 -1
- ksadk/studio/workspace.py +1 -0
- ksadk/version.py +1 -1
- ksadk_runtime_common/workspace_files/path_utils.py +41 -0
- ksadk/server/static/assets/architecture-7GRP2DOG-DjKratdf.js +0 -1
- ksadk/server/static/assets/channel-4cQHKtx1.js +0 -1
- ksadk/server/static/assets/chunk-2Q5K7J3B-DmgWkESh.js +0 -1
- ksadk/server/static/assets/classDiagram-DTDB5LWJ-DLFd9Xi0.js +0 -1
- ksadk/server/static/assets/classDiagram-v2-JRS7N3AN-DLFd9Xi0.js +0 -1
- ksadk/server/static/assets/cynefin-OW5HDTMX-DjEM48Qp.js +0 -1
- ksadk/server/static/assets/eventmodeling-NTZA5JFV-CMgMaJrC.js +0 -1
- ksadk/server/static/assets/flowDiagram-A5DVABFB-BBKL0x3P.js +0 -1
- ksadk/server/static/assets/gitGraph-4MIJSDKK-Dw63mrhw.js +0 -1
- ksadk/server/static/assets/index-8ipRcQ-M.js +0 -240
- ksadk/server/static/assets/index-ByFCqjlh.css +0 -2
- ksadk/server/static/assets/info-A6RAGUB7-B6iAblII.js +0 -1
- ksadk/server/static/assets/packet-AYTQ26CC-BOYAipuq.js +0 -1
- ksadk/server/static/assets/pie-WAS4IAKB-Ctq0Kbku.js +0 -1
- ksadk/server/static/assets/radar-RG4KPBEZ-DN0-ul_0.js +0 -1
- ksadk/server/static/assets/railroad-74A4TZTK-C6cC_to0.js +0 -1
- ksadk/server/static/assets/railroad-abnf-HS5TGJTU-BimjIuRc.js +0 -1
- ksadk/server/static/assets/railroad-ebnf-LZEXJU2U-CvY65ABq.js +0 -1
- ksadk/server/static/assets/railroad-peg-WCYAUIDC-D72Iq_Tv.js +0 -1
- ksadk/server/static/assets/stateDiagram-v2-4QOOHH4V-BgQY03nz.js +0 -1
- ksadk/server/static/assets/swimlanesDiagram-VK2B7HYN-DLVw3q5Q.js +0 -8
- ksadk/server/static/assets/treeView-Q6P3EWNA-BMpO0wsB.js +0 -1
- ksadk/server/static/assets/treemap-WGGIJYW6-Et0aqzU3.js +0 -1
- ksadk/server/static/assets/wardley-WFR3VGLG-CbY0mhpl.js +0 -1
- ksadk/studio/static/assets/index-BLpcdrgW.css +0 -1
- ksadk/studio/static/assets/index-CYekR2Xv.js +0 -260
- {agentengine_sdk_python-0.8.2.dist-info → agentengine_sdk_python-0.8.3.dist-info}/WHEEL +0 -0
- {agentengine_sdk_python-0.8.2.dist-info → agentengine_sdk_python-0.8.3.dist-info}/entry_points.txt +0 -0
- {agentengine_sdk_python-0.8.2.dist-info → agentengine_sdk_python-0.8.3.dist-info}/licenses/LICENSE +0 -0
- {agentengine_sdk_python-0.8.2.dist-info → agentengine_sdk_python-0.8.3.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,1116 @@
|
|
|
1
|
+
"""Transactional DeepSeek Harness profile plugin bridge.
|
|
2
|
+
|
|
3
|
+
DSH remains the native owner of its plugin packages and composed runtime. This
|
|
4
|
+
module only manages one isolated Profile through the public ``dsh plugin`` and
|
|
5
|
+
``--dump-config`` commands, then projects non-secret inventory into KsADK.
|
|
6
|
+
It deliberately does not import Cordis or execute DSH plugin code in PluginHost.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import re
|
|
15
|
+
import shutil
|
|
16
|
+
import stat
|
|
17
|
+
import subprocess
|
|
18
|
+
import tempfile
|
|
19
|
+
import threading
|
|
20
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
21
|
+
from contextlib import contextmanager
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Iterator, Literal, TypedDict
|
|
25
|
+
|
|
26
|
+
try:
|
|
27
|
+
import fcntl
|
|
28
|
+
except ImportError: # pragma: no cover - DSH production hosts are Unix
|
|
29
|
+
fcntl = None # type: ignore[assignment]
|
|
30
|
+
|
|
31
|
+
from pydantic import BaseModel, ConfigDict
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _to_camel(value: str) -> str:
|
|
35
|
+
head, *tail = value.split("_")
|
|
36
|
+
return head + "".join(part.capitalize() for part in tail)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class _DshModel(BaseModel):
|
|
40
|
+
model_config = ConfigDict(
|
|
41
|
+
alias_generator=_to_camel,
|
|
42
|
+
populate_by_name=True,
|
|
43
|
+
extra="forbid",
|
|
44
|
+
frozen=True,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class DshBridgeHost(_DshModel):
|
|
49
|
+
host_id: Literal["deepseek-harness"] = "deepseek-harness"
|
|
50
|
+
version: str
|
|
51
|
+
protocol: Literal["dsh.profile/v1"] = "dsh.profile/v1"
|
|
52
|
+
available: Literal[True] = True
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class DshClientBundle(_DshModel):
|
|
56
|
+
"""One validated browser half from an installed DSH package."""
|
|
57
|
+
|
|
58
|
+
platform: Literal["web"] = "web"
|
|
59
|
+
digest: str
|
|
60
|
+
content_bytes: int
|
|
61
|
+
external: tuple[str, ...] = ()
|
|
62
|
+
inject: tuple[str, ...] = ()
|
|
63
|
+
compatible: bool
|
|
64
|
+
incompatibility_reason: str = ""
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class DshPluginInventory(_DshModel):
|
|
68
|
+
ecosystem: Literal["dsh"] = "dsh"
|
|
69
|
+
integration_mode: Literal["bridged"] = "bridged"
|
|
70
|
+
profile: str
|
|
71
|
+
name: str
|
|
72
|
+
display_name: str
|
|
73
|
+
description: str = ""
|
|
74
|
+
version: str
|
|
75
|
+
requested_spec: str
|
|
76
|
+
source_digest: str | None = None
|
|
77
|
+
source_kind: Literal["directory", "tgz"] | None = None
|
|
78
|
+
installed: Literal[True] = True
|
|
79
|
+
enabled: bool
|
|
80
|
+
permissions_declared: Literal[False] = False
|
|
81
|
+
client_bundle: DshClientBundle | None = None
|
|
82
|
+
risk_disclosures: tuple[str, ...] = (
|
|
83
|
+
"DSH packages and install scripts run with the native host user privileges.",
|
|
84
|
+
"DSH bundle manifests do not declare a complete runtime permission set.",
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class DshProfileProjection(_DshModel):
|
|
89
|
+
profile: str
|
|
90
|
+
bundles: tuple[str, ...]
|
|
91
|
+
config_digest: str
|
|
92
|
+
config_bytes: int
|
|
93
|
+
host_version: str
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class DshBridgeError(RuntimeError):
|
|
97
|
+
"""Base failure for one bounded DSH profile operation."""
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class DshHostUnavailableError(DshBridgeError):
|
|
101
|
+
pass
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class DshPluginNotFoundError(DshBridgeError):
|
|
105
|
+
pass
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class DshPluginApprovalRequired(DshBridgeError):
|
|
109
|
+
pass
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class DshPluginMutationError(DshBridgeError):
|
|
113
|
+
pass
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class _CommandResult(_DshModel):
|
|
117
|
+
stdout: str = ""
|
|
118
|
+
stderr: str = ""
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
CommandRunner = Callable[[Sequence[str], Path, Mapping[str, str]], _CommandResult]
|
|
122
|
+
|
|
123
|
+
_PROFILE_NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
|
|
124
|
+
_PACKAGE_NAME = re.compile(r"^(?:@[A-Za-z0-9._-]+/)?[A-Za-z0-9._-]+$")
|
|
125
|
+
_HOST_VERSION = re.compile(r"\b(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)\b")
|
|
126
|
+
_STATE_FILE = ".ksadk-dsh-plugins.json"
|
|
127
|
+
_IMMUTABLE_SOURCE_DIR = "immutable-plugin-sources"
|
|
128
|
+
_SNAPSHOT_FILES = ("package.json", "pnpm-lock.yaml", "pnpm-workspace.yaml", _STATE_FILE)
|
|
129
|
+
_MAX_JSON_BYTES = 2 * 1024 * 1024
|
|
130
|
+
_MAX_CLIENT_BUNDLE_BYTES = 8 * 1024 * 1024
|
|
131
|
+
_STUDIO_CLIENT_EXTERNALS = frozenset({"react"})
|
|
132
|
+
_PROFILE_LOCK_DIR = "profile-locks"
|
|
133
|
+
_DSH_SUBPROCESS_ENV_KEYS = (
|
|
134
|
+
"PATH",
|
|
135
|
+
"HOME",
|
|
136
|
+
"USER",
|
|
137
|
+
"LOGNAME",
|
|
138
|
+
"SHELL",
|
|
139
|
+
"TMPDIR",
|
|
140
|
+
"TMP",
|
|
141
|
+
"TEMP",
|
|
142
|
+
"LANG",
|
|
143
|
+
"LANGUAGE",
|
|
144
|
+
"LC_ALL",
|
|
145
|
+
"LC_CTYPE",
|
|
146
|
+
"HTTP_PROXY",
|
|
147
|
+
"HTTPS_PROXY",
|
|
148
|
+
"ALL_PROXY",
|
|
149
|
+
"NO_PROXY",
|
|
150
|
+
"http_proxy",
|
|
151
|
+
"https_proxy",
|
|
152
|
+
"all_proxy",
|
|
153
|
+
"no_proxy",
|
|
154
|
+
"SSL_CERT_FILE",
|
|
155
|
+
"SSL_CERT_DIR",
|
|
156
|
+
"NODE_EXTRA_CA_CERTS",
|
|
157
|
+
"REQUESTS_CA_BUNDLE",
|
|
158
|
+
"CURL_CA_BUNDLE",
|
|
159
|
+
"NPM_CONFIG_REGISTRY",
|
|
160
|
+
"npm_config_registry",
|
|
161
|
+
"SYSTEMROOT",
|
|
162
|
+
"WINDIR",
|
|
163
|
+
"COMSPEC",
|
|
164
|
+
"PATHEXT",
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def dsh_subprocess_environment(*, dsh_home: Path | None = None) -> dict[str, str]:
|
|
169
|
+
"""Build the explicit environment inherited by DSH and pnpm children.
|
|
170
|
+
|
|
171
|
+
DSH bundles may execute package lifecycle scripts. They need process
|
|
172
|
+
discovery, a home/temp directory, locale, proxies and CA configuration,
|
|
173
|
+
but never receive arbitrary model, cloud, npm, SSH or host application
|
|
174
|
+
credentials from the parent process.
|
|
175
|
+
"""
|
|
176
|
+
|
|
177
|
+
environment = {
|
|
178
|
+
name: os.environ[name] for name in _DSH_SUBPROCESS_ENV_KEYS if os.environ.get(name)
|
|
179
|
+
}
|
|
180
|
+
if "PATH" not in environment:
|
|
181
|
+
environment["PATH"] = os.defpath
|
|
182
|
+
if dsh_home is not None:
|
|
183
|
+
environment["DSH_HOME"] = str(dsh_home)
|
|
184
|
+
return environment
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
class _SourceReceipt(TypedDict):
|
|
188
|
+
digest: str
|
|
189
|
+
kind: Literal["directory", "tgz"]
|
|
190
|
+
artifact: str
|
|
191
|
+
dependency_spec: str
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
class _ProfileState(TypedDict):
|
|
195
|
+
order: list[str]
|
|
196
|
+
disabled: list[str]
|
|
197
|
+
sources: dict[str, _SourceReceipt]
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
@dataclass(frozen=True)
|
|
201
|
+
class _PreparedSource:
|
|
202
|
+
command_source: str
|
|
203
|
+
digest: str
|
|
204
|
+
kind: Literal["directory", "tgz"]
|
|
205
|
+
artifact: str
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
class DshProfilePluginBridge:
|
|
209
|
+
"""Manage DSH bundles in one isolated Profile with rollback and preflight."""
|
|
210
|
+
|
|
211
|
+
def __init__(
|
|
212
|
+
self,
|
|
213
|
+
*,
|
|
214
|
+
dsh_home: Path,
|
|
215
|
+
profile: str = "ksadk",
|
|
216
|
+
dsh_command: Sequence[str] | None = None,
|
|
217
|
+
command_runner: CommandRunner | None = None,
|
|
218
|
+
cwd: Path | None = None,
|
|
219
|
+
) -> None:
|
|
220
|
+
if not _PROFILE_NAME.fullmatch(profile):
|
|
221
|
+
raise ValueError("DSH profile must be a simple name without path separators")
|
|
222
|
+
if dsh_command is not None and not dsh_command:
|
|
223
|
+
raise ValueError("DSH command cannot be empty")
|
|
224
|
+
self._dsh_home = dsh_home.expanduser().resolve()
|
|
225
|
+
self._profile = profile
|
|
226
|
+
self._profile_root = self._dsh_home / "profiles" / profile
|
|
227
|
+
self._command = tuple(dsh_command) if dsh_command is not None else None
|
|
228
|
+
self._runner = command_runner or self._run_command
|
|
229
|
+
self._cwd = (cwd or Path.cwd()).resolve()
|
|
230
|
+
self._host: DshBridgeHost | None = None
|
|
231
|
+
self._lock = threading.RLock()
|
|
232
|
+
self._transaction_local = threading.local()
|
|
233
|
+
|
|
234
|
+
@property
|
|
235
|
+
def host(self) -> DshBridgeHost:
|
|
236
|
+
if self._host is None:
|
|
237
|
+
raise DshHostUnavailableError("DSH bridge is not started")
|
|
238
|
+
return self._host
|
|
239
|
+
|
|
240
|
+
def start(self) -> DshBridgeHost:
|
|
241
|
+
if self._host is not None:
|
|
242
|
+
return self._host
|
|
243
|
+
command = self._resolve_command()
|
|
244
|
+
result = self._invoke((*command, "--version"), cwd=self._cwd)
|
|
245
|
+
match = _HOST_VERSION.search(result.stdout or result.stderr)
|
|
246
|
+
if match is None:
|
|
247
|
+
raise DshHostUnavailableError("DSH host did not report a parseable version")
|
|
248
|
+
self._command = command
|
|
249
|
+
self._host = DshBridgeHost(version=match.group(1))
|
|
250
|
+
return self._host
|
|
251
|
+
|
|
252
|
+
def close(self) -> None:
|
|
253
|
+
self._host = None
|
|
254
|
+
|
|
255
|
+
def __enter__(self) -> "DshProfilePluginBridge":
|
|
256
|
+
self.start()
|
|
257
|
+
return self
|
|
258
|
+
|
|
259
|
+
def __exit__(self, _exc_type: object, _exc: object, _tb: object) -> None:
|
|
260
|
+
self.close()
|
|
261
|
+
|
|
262
|
+
def list_plugins(self) -> tuple[DshPluginInventory, ...]:
|
|
263
|
+
with self._profile_transaction(exclusive=False):
|
|
264
|
+
return self._list_plugins_locked()
|
|
265
|
+
|
|
266
|
+
def _list_plugins_locked(self) -> tuple[DshPluginInventory, ...]:
|
|
267
|
+
self._ensure_started()
|
|
268
|
+
if not self._manifest_path().is_file():
|
|
269
|
+
return ()
|
|
270
|
+
manifest = self._read_manifest()
|
|
271
|
+
state = self._read_state(manifest)
|
|
272
|
+
self._verify_source_receipts(manifest, state)
|
|
273
|
+
active = set(self._bundles(manifest))
|
|
274
|
+
items: list[DshPluginInventory] = []
|
|
275
|
+
for name, requested_spec in self._dependencies(manifest).items():
|
|
276
|
+
package = self._read_package(name)
|
|
277
|
+
if package is None or self._bundle_patch(package) is None:
|
|
278
|
+
continue
|
|
279
|
+
receipt = state["sources"].get(name)
|
|
280
|
+
items.append(
|
|
281
|
+
DshPluginInventory(
|
|
282
|
+
profile=self._profile,
|
|
283
|
+
name=name,
|
|
284
|
+
display_name=self._string(package.get("displayName"))
|
|
285
|
+
or self._string(package.get("name"))
|
|
286
|
+
or name,
|
|
287
|
+
description=self._string(package.get("description")),
|
|
288
|
+
version=self._string(package.get("version")),
|
|
289
|
+
requested_spec=requested_spec,
|
|
290
|
+
source_digest=receipt["digest"] if receipt is not None else None,
|
|
291
|
+
source_kind=receipt["kind"] if receipt is not None else None,
|
|
292
|
+
enabled=name in active and name not in set(state["disabled"]),
|
|
293
|
+
client_bundle=self._client_bundle_metadata(name, package),
|
|
294
|
+
)
|
|
295
|
+
)
|
|
296
|
+
return tuple(sorted(items, key=lambda item: (item.display_name.casefold(), item.name)))
|
|
297
|
+
|
|
298
|
+
def get_plugin(self, name: str) -> DshPluginInventory:
|
|
299
|
+
self._validate_package_name(name)
|
|
300
|
+
matches = [item for item in self.list_plugins() if item.name == name]
|
|
301
|
+
if not matches:
|
|
302
|
+
raise DshPluginNotFoundError(f"DSH plugin {name!r} is not installed")
|
|
303
|
+
return matches[0]
|
|
304
|
+
|
|
305
|
+
def install_plugin(
|
|
306
|
+
self,
|
|
307
|
+
source: str,
|
|
308
|
+
*,
|
|
309
|
+
accept_host_permissions: bool = False,
|
|
310
|
+
) -> DshPluginInventory:
|
|
311
|
+
if not accept_host_permissions:
|
|
312
|
+
raise DshPluginApprovalRequired(
|
|
313
|
+
"DSH packages can run install scripts and runtime code with host privileges; "
|
|
314
|
+
"explicit approval is required"
|
|
315
|
+
)
|
|
316
|
+
self._validate_source(source)
|
|
317
|
+
with self._profile_transaction(exclusive=True):
|
|
318
|
+
self._require_package_mutation_rollback(new_profile_allowed=True)
|
|
319
|
+
snapshot = self._snapshot()
|
|
320
|
+
before = (
|
|
321
|
+
self._dependencies(self._read_manifest()) if self._manifest_path().is_file() else {}
|
|
322
|
+
)
|
|
323
|
+
try:
|
|
324
|
+
if self._manifest_path().is_file():
|
|
325
|
+
existing_manifest = self._read_manifest()
|
|
326
|
+
self._verify_source_receipts(
|
|
327
|
+
existing_manifest, self._read_state(existing_manifest)
|
|
328
|
+
)
|
|
329
|
+
prepared = self._prepare_source(source)
|
|
330
|
+
self._plugin_command("add", prepared.command_source)
|
|
331
|
+
manifest = self._read_manifest()
|
|
332
|
+
added = [name for name in self._dependencies(manifest) if name not in before]
|
|
333
|
+
if len(added) != 1:
|
|
334
|
+
raise DshPluginMutationError(
|
|
335
|
+
"installing one DSH plugin must add exactly one direct dependency"
|
|
336
|
+
)
|
|
337
|
+
name = added[0]
|
|
338
|
+
self._require_bundle(name)
|
|
339
|
+
state = self._read_state(manifest)
|
|
340
|
+
state["order"] = [item for item in state["order"] if item != name] + [name]
|
|
341
|
+
if name not in state["disabled"]:
|
|
342
|
+
state["disabled"].append(name)
|
|
343
|
+
if prepared.digest:
|
|
344
|
+
state["sources"][name] = {
|
|
345
|
+
"digest": prepared.digest,
|
|
346
|
+
"kind": prepared.kind,
|
|
347
|
+
"artifact": prepared.artifact,
|
|
348
|
+
"dependency_spec": self._dependencies(manifest)[name],
|
|
349
|
+
}
|
|
350
|
+
self._write_state(state)
|
|
351
|
+
self._write_active_bundles(manifest, state)
|
|
352
|
+
self._preflight()
|
|
353
|
+
return self.get_plugin(name)
|
|
354
|
+
except BaseException as error:
|
|
355
|
+
self._rollback(snapshot, error)
|
|
356
|
+
raise
|
|
357
|
+
|
|
358
|
+
def set_enabled(self, name: str, *, enabled: bool) -> DshPluginInventory:
|
|
359
|
+
self._validate_package_name(name)
|
|
360
|
+
with self._profile_transaction(exclusive=True):
|
|
361
|
+
snapshot = self._snapshot()
|
|
362
|
+
try:
|
|
363
|
+
manifest = self._read_manifest()
|
|
364
|
+
if name not in self._dependencies(manifest):
|
|
365
|
+
raise DshPluginNotFoundError(f"DSH plugin {name!r} is not installed")
|
|
366
|
+
self._require_bundle(name)
|
|
367
|
+
state = self._read_state(manifest)
|
|
368
|
+
self._verify_source_receipts(manifest, state, names=(name,))
|
|
369
|
+
if name not in state["order"]:
|
|
370
|
+
state["order"].append(name)
|
|
371
|
+
if enabled:
|
|
372
|
+
state["disabled"] = [item for item in state["disabled"] if item != name]
|
|
373
|
+
elif name not in state["disabled"]:
|
|
374
|
+
state["disabled"].append(name)
|
|
375
|
+
self._write_state(state)
|
|
376
|
+
self._write_active_bundles(manifest, state)
|
|
377
|
+
self._preflight()
|
|
378
|
+
return self.get_plugin(name)
|
|
379
|
+
except BaseException as error:
|
|
380
|
+
self._rollback(snapshot, error)
|
|
381
|
+
raise
|
|
382
|
+
|
|
383
|
+
def update_plugin(
|
|
384
|
+
self,
|
|
385
|
+
name: str,
|
|
386
|
+
*,
|
|
387
|
+
source: str | None = None,
|
|
388
|
+
accept_host_permissions: bool = False,
|
|
389
|
+
) -> DshPluginInventory:
|
|
390
|
+
self._validate_package_name(name)
|
|
391
|
+
if not accept_host_permissions:
|
|
392
|
+
raise DshPluginApprovalRequired(
|
|
393
|
+
"updating a DSH package requires host permission approval"
|
|
394
|
+
)
|
|
395
|
+
with self._profile_transaction(exclusive=True):
|
|
396
|
+
self._require_package_mutation_rollback(new_profile_allowed=False)
|
|
397
|
+
snapshot = self._snapshot()
|
|
398
|
+
try:
|
|
399
|
+
self.get_plugin(name)
|
|
400
|
+
before_manifest = self._read_manifest()
|
|
401
|
+
before_state = self._read_state(before_manifest)
|
|
402
|
+
self._verify_source_receipts(before_manifest, before_state, names=(name,))
|
|
403
|
+
existing_source = before_state["sources"].get(name)
|
|
404
|
+
if existing_source is not None and source is None:
|
|
405
|
+
raise DshPluginMutationError(
|
|
406
|
+
"updating an immutable local DSH plugin requires an explicit source"
|
|
407
|
+
)
|
|
408
|
+
prepared = None
|
|
409
|
+
if source is not None:
|
|
410
|
+
self._validate_source(source)
|
|
411
|
+
prepared = self._prepare_source(source)
|
|
412
|
+
if not prepared.digest:
|
|
413
|
+
raise DshPluginMutationError(
|
|
414
|
+
"an explicit update source must be a local directory or tgz"
|
|
415
|
+
)
|
|
416
|
+
self._plugin_command("add", prepared.command_source)
|
|
417
|
+
else:
|
|
418
|
+
self._plugin_command("update", name)
|
|
419
|
+
manifest = self._read_manifest()
|
|
420
|
+
if prepared is not None:
|
|
421
|
+
changed = {
|
|
422
|
+
dependency
|
|
423
|
+
for dependency in (
|
|
424
|
+
set(self._dependencies(before_manifest))
|
|
425
|
+
| set(self._dependencies(manifest))
|
|
426
|
+
)
|
|
427
|
+
if self._dependencies(before_manifest).get(dependency)
|
|
428
|
+
!= self._dependencies(manifest).get(dependency)
|
|
429
|
+
}
|
|
430
|
+
if changed != {name}:
|
|
431
|
+
raise DshPluginMutationError(
|
|
432
|
+
"updated local source must replace exactly the selected plugin"
|
|
433
|
+
)
|
|
434
|
+
self._require_bundle(name)
|
|
435
|
+
state = self._read_state(manifest)
|
|
436
|
+
if prepared is not None:
|
|
437
|
+
if name not in self._dependencies(manifest):
|
|
438
|
+
raise DshPluginMutationError(
|
|
439
|
+
"updated local source did not preserve the plugin package name"
|
|
440
|
+
)
|
|
441
|
+
state["sources"][name] = {
|
|
442
|
+
"digest": prepared.digest,
|
|
443
|
+
"kind": prepared.kind,
|
|
444
|
+
"artifact": prepared.artifact,
|
|
445
|
+
"dependency_spec": self._dependencies(manifest)[name],
|
|
446
|
+
}
|
|
447
|
+
self._write_state(state)
|
|
448
|
+
self._write_active_bundles(manifest, state)
|
|
449
|
+
self._verify_source_receipts(manifest, state, names=(name,))
|
|
450
|
+
self._preflight()
|
|
451
|
+
return self.get_plugin(name)
|
|
452
|
+
except BaseException as error:
|
|
453
|
+
self._rollback(snapshot, error)
|
|
454
|
+
raise
|
|
455
|
+
|
|
456
|
+
def uninstall_plugin(self, name: str) -> None:
|
|
457
|
+
self._validate_package_name(name)
|
|
458
|
+
with self._profile_transaction(exclusive=True):
|
|
459
|
+
self._require_package_mutation_rollback(new_profile_allowed=False)
|
|
460
|
+
snapshot = self._snapshot()
|
|
461
|
+
try:
|
|
462
|
+
self.get_plugin(name)
|
|
463
|
+
self._plugin_command("remove", name)
|
|
464
|
+
manifest = self._read_manifest()
|
|
465
|
+
state = self._read_state(manifest)
|
|
466
|
+
state["order"] = [item for item in state["order"] if item != name]
|
|
467
|
+
state["disabled"] = [item for item in state["disabled"] if item != name]
|
|
468
|
+
state["sources"].pop(name, None)
|
|
469
|
+
self._write_state(state)
|
|
470
|
+
self._write_active_bundles(manifest, state)
|
|
471
|
+
self._preflight()
|
|
472
|
+
if any(item.name == name for item in self.list_plugins()):
|
|
473
|
+
raise DshPluginMutationError("DSH host still reports the removed plugin")
|
|
474
|
+
except BaseException as error:
|
|
475
|
+
self._rollback(snapshot, error)
|
|
476
|
+
raise
|
|
477
|
+
|
|
478
|
+
def project_profile(self) -> DshProfileProjection:
|
|
479
|
+
"""Validate the native profile and expose only its digest, never raw config."""
|
|
480
|
+
|
|
481
|
+
with self._profile_transaction(exclusive=False):
|
|
482
|
+
self._ensure_started()
|
|
483
|
+
manifest = self._read_manifest()
|
|
484
|
+
self._verify_source_receipts(manifest, self._read_state(manifest))
|
|
485
|
+
result = self._preflight()
|
|
486
|
+
payload = result.stdout.encode("utf-8")
|
|
487
|
+
return DshProfileProjection(
|
|
488
|
+
profile=self._profile,
|
|
489
|
+
bundles=tuple(self._bundles(manifest)),
|
|
490
|
+
config_digest=f"sha256:{hashlib.sha256(payload).hexdigest()}",
|
|
491
|
+
config_bytes=len(payload),
|
|
492
|
+
host_version=self.host.version,
|
|
493
|
+
)
|
|
494
|
+
|
|
495
|
+
def read_client_bundle(self, name: str, *, expected_digest: str) -> bytes:
|
|
496
|
+
"""Read one immutable browser artifact after revalidating Profile inventory."""
|
|
497
|
+
|
|
498
|
+
with self._profile_transaction(exclusive=False):
|
|
499
|
+
return self._read_client_bundle_locked(name, expected_digest=expected_digest)
|
|
500
|
+
|
|
501
|
+
def _read_client_bundle_locked(self, name: str, *, expected_digest: str) -> bytes:
|
|
502
|
+
item = self.get_plugin(name)
|
|
503
|
+
if not item.enabled:
|
|
504
|
+
raise DshPluginNotFoundError(f"DSH plugin {name!r} is disabled")
|
|
505
|
+
client = item.client_bundle
|
|
506
|
+
if client is None or not client.compatible:
|
|
507
|
+
raise DshPluginNotFoundError(
|
|
508
|
+
f"DSH plugin {name!r} has no Studio-compatible client bundle"
|
|
509
|
+
)
|
|
510
|
+
if client.digest != expected_digest:
|
|
511
|
+
raise DshPluginMutationError("DSH client bundle digest fence does not match")
|
|
512
|
+
package = self._read_package(name)
|
|
513
|
+
assert package is not None
|
|
514
|
+
path = self._client_bundle_path(name, package)
|
|
515
|
+
if path is None:
|
|
516
|
+
raise DshPluginNotFoundError(f"DSH plugin {name!r} client bundle is unavailable")
|
|
517
|
+
try:
|
|
518
|
+
content = path.read_bytes()
|
|
519
|
+
except OSError as error:
|
|
520
|
+
raise DshPluginMutationError("DSH client bundle became unreadable") from error
|
|
521
|
+
actual_digest = f"sha256:{hashlib.sha256(content).hexdigest()}"
|
|
522
|
+
if actual_digest != expected_digest:
|
|
523
|
+
raise DshPluginMutationError("DSH client bundle changed after inventory projection")
|
|
524
|
+
return content
|
|
525
|
+
|
|
526
|
+
def _resolve_command(self) -> tuple[str, ...]:
|
|
527
|
+
if self._command is not None:
|
|
528
|
+
return self._command
|
|
529
|
+
executable = shutil.which("dsh")
|
|
530
|
+
if executable is None:
|
|
531
|
+
raise DshHostUnavailableError(
|
|
532
|
+
"DSH host is not installed; configure an exact DSH executable "
|
|
533
|
+
"before using the bridge"
|
|
534
|
+
)
|
|
535
|
+
return (executable,)
|
|
536
|
+
|
|
537
|
+
def _ensure_started(self) -> None:
|
|
538
|
+
if self._host is None or self._command is None:
|
|
539
|
+
raise DshHostUnavailableError("DSH bridge is not started")
|
|
540
|
+
|
|
541
|
+
@contextmanager
|
|
542
|
+
def _profile_transaction(self, *, exclusive: bool) -> Iterator[None]:
|
|
543
|
+
"""Serialize one Profile snapshot across bridge objects and processes."""
|
|
544
|
+
|
|
545
|
+
with self._lock:
|
|
546
|
+
depth = int(getattr(self._transaction_local, "depth", 0))
|
|
547
|
+
held_exclusive = bool(getattr(self._transaction_local, "exclusive", False))
|
|
548
|
+
if depth:
|
|
549
|
+
if exclusive and not held_exclusive:
|
|
550
|
+
raise DshPluginMutationError(
|
|
551
|
+
"cannot upgrade a DSH profile read transaction to a mutation"
|
|
552
|
+
)
|
|
553
|
+
self._transaction_local.depth = depth + 1
|
|
554
|
+
try:
|
|
555
|
+
yield
|
|
556
|
+
finally:
|
|
557
|
+
self._transaction_local.depth = depth
|
|
558
|
+
return
|
|
559
|
+
|
|
560
|
+
if fcntl is None:
|
|
561
|
+
raise DshHostUnavailableError("DSH profile transactions require Unix file locking")
|
|
562
|
+
lock_root = self._dsh_home / _PROFILE_LOCK_DIR
|
|
563
|
+
lock_root.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
564
|
+
if lock_root.resolve() != lock_root:
|
|
565
|
+
raise DshPluginMutationError("DSH profile lock directory is not trusted")
|
|
566
|
+
lock_path = lock_root / f"{self._profile}.lock"
|
|
567
|
+
flags = os.O_CREAT | os.O_RDWR
|
|
568
|
+
if hasattr(os, "O_NOFOLLOW"):
|
|
569
|
+
flags |= os.O_NOFOLLOW
|
|
570
|
+
try:
|
|
571
|
+
descriptor = os.open(lock_path, flags, 0o600)
|
|
572
|
+
except OSError as error:
|
|
573
|
+
raise DshPluginMutationError(
|
|
574
|
+
"DSH profile transaction lock is unavailable"
|
|
575
|
+
) from error
|
|
576
|
+
try:
|
|
577
|
+
metadata = os.fstat(descriptor)
|
|
578
|
+
if not stat.S_ISREG(metadata.st_mode):
|
|
579
|
+
raise DshPluginMutationError(
|
|
580
|
+
"DSH profile transaction lock is not a regular file"
|
|
581
|
+
)
|
|
582
|
+
os.fchmod(descriptor, 0o600)
|
|
583
|
+
fcntl.flock(
|
|
584
|
+
descriptor,
|
|
585
|
+
fcntl.LOCK_EX if exclusive else fcntl.LOCK_SH,
|
|
586
|
+
)
|
|
587
|
+
self._transaction_local.depth = 1
|
|
588
|
+
self._transaction_local.exclusive = exclusive
|
|
589
|
+
try:
|
|
590
|
+
yield
|
|
591
|
+
finally:
|
|
592
|
+
self._transaction_local.depth = 0
|
|
593
|
+
self._transaction_local.exclusive = False
|
|
594
|
+
fcntl.flock(descriptor, fcntl.LOCK_UN)
|
|
595
|
+
finally:
|
|
596
|
+
os.close(descriptor)
|
|
597
|
+
|
|
598
|
+
def _invoke(self, command: Sequence[str], *, cwd: Path) -> _CommandResult:
|
|
599
|
+
environment = dsh_subprocess_environment(dsh_home=self._dsh_home)
|
|
600
|
+
try:
|
|
601
|
+
return self._runner(tuple(command), cwd, environment)
|
|
602
|
+
except DshBridgeError:
|
|
603
|
+
raise
|
|
604
|
+
except Exception as error:
|
|
605
|
+
raise DshHostUnavailableError("DSH host command could not be executed") from error
|
|
606
|
+
|
|
607
|
+
def _plugin_command(self, verb: str, value: str) -> _CommandResult:
|
|
608
|
+
self._ensure_started()
|
|
609
|
+
assert self._command is not None
|
|
610
|
+
return self._invoke(
|
|
611
|
+
(*self._command, "plugin", "--profile", self._profile, verb, value),
|
|
612
|
+
cwd=self._cwd,
|
|
613
|
+
)
|
|
614
|
+
|
|
615
|
+
def _preflight(self) -> _CommandResult:
|
|
616
|
+
self._ensure_started()
|
|
617
|
+
assert self._command is not None
|
|
618
|
+
return self._invoke(
|
|
619
|
+
(*self._command, "--profile", self._profile, "--dump-config"),
|
|
620
|
+
cwd=self._cwd,
|
|
621
|
+
)
|
|
622
|
+
|
|
623
|
+
def _manifest_path(self) -> Path:
|
|
624
|
+
return self._profile_root / "package.json"
|
|
625
|
+
|
|
626
|
+
def _read_manifest(self) -> dict[str, object]:
|
|
627
|
+
return self._read_json(self._manifest_path(), required=True)
|
|
628
|
+
|
|
629
|
+
def _read_package(self, name: str) -> dict[str, object] | None:
|
|
630
|
+
return self._read_json(
|
|
631
|
+
self._profile_root / "node_modules" / Path(*name.split("/")) / "package.json",
|
|
632
|
+
required=False,
|
|
633
|
+
)
|
|
634
|
+
|
|
635
|
+
@staticmethod
|
|
636
|
+
def _read_json(path: Path, *, required: bool) -> dict[str, object] | None:
|
|
637
|
+
try:
|
|
638
|
+
if path.stat().st_size > _MAX_JSON_BYTES:
|
|
639
|
+
raise DshBridgeError("DSH manifest exceeds the supported size limit")
|
|
640
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
641
|
+
except FileNotFoundError:
|
|
642
|
+
if required:
|
|
643
|
+
raise DshBridgeError("required DSH profile manifest is unavailable") from None
|
|
644
|
+
return None
|
|
645
|
+
except (OSError, UnicodeError, json.JSONDecodeError) as error:
|
|
646
|
+
raise DshBridgeError("DSH profile contains an unreadable JSON manifest") from error
|
|
647
|
+
if not isinstance(payload, dict):
|
|
648
|
+
raise DshBridgeError("DSH JSON manifest must be an object")
|
|
649
|
+
return payload
|
|
650
|
+
|
|
651
|
+
@staticmethod
|
|
652
|
+
def _dependencies(manifest: Mapping[str, object]) -> dict[str, str]:
|
|
653
|
+
raw = manifest.get("dependencies")
|
|
654
|
+
if raw is None:
|
|
655
|
+
return {}
|
|
656
|
+
if not isinstance(raw, dict) or any(
|
|
657
|
+
not isinstance(name, str) or not isinstance(value, str) for name, value in raw.items()
|
|
658
|
+
):
|
|
659
|
+
raise DshBridgeError("DSH profile dependencies are invalid")
|
|
660
|
+
return dict(raw)
|
|
661
|
+
|
|
662
|
+
@staticmethod
|
|
663
|
+
def _bundles(manifest: Mapping[str, object]) -> list[str]:
|
|
664
|
+
dsh = manifest.get("dsh")
|
|
665
|
+
profile = dsh.get("profile") if isinstance(dsh, dict) else None
|
|
666
|
+
bundles = profile.get("bundles") if isinstance(profile, dict) else None
|
|
667
|
+
if bundles is None:
|
|
668
|
+
return []
|
|
669
|
+
if not isinstance(bundles, list) or any(not isinstance(item, str) for item in bundles):
|
|
670
|
+
raise DshBridgeError("DSH profile bundle order is invalid")
|
|
671
|
+
return list(bundles)
|
|
672
|
+
|
|
673
|
+
@staticmethod
|
|
674
|
+
def _bundle_patch(package: Mapping[str, object]) -> str | None:
|
|
675
|
+
dsh = package.get("dsh")
|
|
676
|
+
bundle = dsh.get("bundle") if isinstance(dsh, dict) else None
|
|
677
|
+
patch = bundle.get("patch") if isinstance(bundle, dict) else None
|
|
678
|
+
return patch.strip() if isinstance(patch, str) and patch.strip() else None
|
|
679
|
+
|
|
680
|
+
@staticmethod
|
|
681
|
+
def _client_declaration(package: Mapping[str, object]) -> Mapping[str, object] | None:
|
|
682
|
+
dsh = package.get("dsh")
|
|
683
|
+
client = dsh.get("client") if isinstance(dsh, dict) else None
|
|
684
|
+
return client if isinstance(client, dict) else None
|
|
685
|
+
|
|
686
|
+
@staticmethod
|
|
687
|
+
def _string_tuple(value: object) -> tuple[str, ...] | None:
|
|
688
|
+
if value is None:
|
|
689
|
+
return ()
|
|
690
|
+
if not isinstance(value, list) or any(not isinstance(item, str) for item in value):
|
|
691
|
+
return None
|
|
692
|
+
return tuple(value)
|
|
693
|
+
|
|
694
|
+
@staticmethod
|
|
695
|
+
def _client_export(package: Mapping[str, object]) -> str | None:
|
|
696
|
+
exports = package.get("exports")
|
|
697
|
+
client = exports.get("./client") if isinstance(exports, dict) else None
|
|
698
|
+
if isinstance(client, str):
|
|
699
|
+
return client
|
|
700
|
+
default = client.get("default") if isinstance(client, dict) else None
|
|
701
|
+
return default if isinstance(default, str) else None
|
|
702
|
+
|
|
703
|
+
def _client_bundle_path(
|
|
704
|
+
self, name: str, package: Mapping[str, object]
|
|
705
|
+
) -> Path | None:
|
|
706
|
+
declared = self._client_export(package)
|
|
707
|
+
if not declared:
|
|
708
|
+
return None
|
|
709
|
+
relative = Path(declared)
|
|
710
|
+
if relative.is_absolute() or ".." in relative.parts:
|
|
711
|
+
return None
|
|
712
|
+
root = (self._profile_root / "node_modules" / Path(*name.split("/"))).resolve()
|
|
713
|
+
target = (root / relative).resolve()
|
|
714
|
+
if not target.is_relative_to(root) or not target.is_file():
|
|
715
|
+
return None
|
|
716
|
+
return target
|
|
717
|
+
|
|
718
|
+
def _client_bundle_metadata(
|
|
719
|
+
self, name: str, package: Mapping[str, object]
|
|
720
|
+
) -> DshClientBundle | None:
|
|
721
|
+
declaration = self._client_declaration(package)
|
|
722
|
+
if declaration is None or declaration.get("platform") != "web":
|
|
723
|
+
return None
|
|
724
|
+
inject = self._string_tuple(declaration.get("inject"))
|
|
725
|
+
external = self._string_tuple(declaration.get("external"))
|
|
726
|
+
target = self._client_bundle_path(name, package)
|
|
727
|
+
reason = ""
|
|
728
|
+
if inject is None or external is None:
|
|
729
|
+
reason = "dsh.client inject/external must be string arrays"
|
|
730
|
+
elif target is None:
|
|
731
|
+
reason = "exports[./client] does not resolve to a built bundle"
|
|
732
|
+
elif inject:
|
|
733
|
+
reason = "client bundle dependencies are not present in the Studio graph"
|
|
734
|
+
elif any(item not in _STUDIO_CLIENT_EXTERNALS for item in external):
|
|
735
|
+
reason = "client bundle requests unsupported external modules"
|
|
736
|
+
if target is None:
|
|
737
|
+
return DshClientBundle(
|
|
738
|
+
digest="",
|
|
739
|
+
content_bytes=0,
|
|
740
|
+
inject=inject or (),
|
|
741
|
+
external=external or (),
|
|
742
|
+
compatible=False,
|
|
743
|
+
incompatibility_reason=reason,
|
|
744
|
+
)
|
|
745
|
+
try:
|
|
746
|
+
size = target.stat().st_size
|
|
747
|
+
if size > _MAX_CLIENT_BUNDLE_BYTES:
|
|
748
|
+
reason = "client bundle exceeds the supported size limit"
|
|
749
|
+
content = b""
|
|
750
|
+
else:
|
|
751
|
+
content = target.read_bytes()
|
|
752
|
+
except OSError:
|
|
753
|
+
size = 0
|
|
754
|
+
content = b""
|
|
755
|
+
reason = "client bundle is unreadable"
|
|
756
|
+
return DshClientBundle(
|
|
757
|
+
digest=f"sha256:{hashlib.sha256(content).hexdigest()}" if content else "",
|
|
758
|
+
content_bytes=size,
|
|
759
|
+
inject=inject or (),
|
|
760
|
+
external=external or (),
|
|
761
|
+
compatible=not reason,
|
|
762
|
+
incompatibility_reason=reason,
|
|
763
|
+
)
|
|
764
|
+
|
|
765
|
+
def _require_bundle(self, name: str) -> None:
|
|
766
|
+
package = self._read_package(name)
|
|
767
|
+
patch = self._bundle_patch(package or {})
|
|
768
|
+
if package is None or patch is None:
|
|
769
|
+
raise DshPluginMutationError(f"{name} does not declare dsh.bundle.patch")
|
|
770
|
+
relative = Path(patch)
|
|
771
|
+
if relative.is_absolute() or ".." in relative.parts:
|
|
772
|
+
raise DshPluginMutationError(f"{name} declares an unsafe bundle patch path")
|
|
773
|
+
root = (self._profile_root / "node_modules" / Path(*name.split("/"))).resolve()
|
|
774
|
+
target = (root / relative).resolve()
|
|
775
|
+
if not target.is_relative_to(root) or not target.is_file():
|
|
776
|
+
raise DshPluginMutationError(f"{name} bundle patch is unavailable")
|
|
777
|
+
|
|
778
|
+
def _read_state(self, manifest: Mapping[str, object]) -> _ProfileState:
|
|
779
|
+
dependencies = set(self._dependencies(manifest))
|
|
780
|
+
bundle_dependencies = [
|
|
781
|
+
name
|
|
782
|
+
for name in dependencies
|
|
783
|
+
if self._bundle_patch(self._read_package(name) or {}) is not None
|
|
784
|
+
]
|
|
785
|
+
active = [name for name in self._bundles(manifest) if name in dependencies]
|
|
786
|
+
stored = self._read_json(self._profile_root / _STATE_FILE, required=False) or {}
|
|
787
|
+
order_raw = stored.get("order")
|
|
788
|
+
disabled_raw = stored.get("disabled")
|
|
789
|
+
sources_raw = stored.get("sources")
|
|
790
|
+
order = (
|
|
791
|
+
[item for item in order_raw if isinstance(item, str) and item in bundle_dependencies]
|
|
792
|
+
if isinstance(order_raw, list)
|
|
793
|
+
else []
|
|
794
|
+
)
|
|
795
|
+
for name in [*active, *bundle_dependencies]:
|
|
796
|
+
if name not in order:
|
|
797
|
+
order.append(name)
|
|
798
|
+
disabled = (
|
|
799
|
+
[item for item in disabled_raw if isinstance(item, str) and item in bundle_dependencies]
|
|
800
|
+
if isinstance(disabled_raw, list)
|
|
801
|
+
else [name for name in bundle_dependencies if name not in active]
|
|
802
|
+
)
|
|
803
|
+
sources: dict[str, _SourceReceipt] = {}
|
|
804
|
+
if isinstance(sources_raw, dict):
|
|
805
|
+
for name, raw in sources_raw.items():
|
|
806
|
+
if name not in bundle_dependencies:
|
|
807
|
+
continue
|
|
808
|
+
if not isinstance(raw, dict):
|
|
809
|
+
raise DshPluginMutationError("DSH local source receipt is invalid")
|
|
810
|
+
digest = raw.get("digest")
|
|
811
|
+
kind = raw.get("kind")
|
|
812
|
+
artifact = raw.get("artifact")
|
|
813
|
+
dependency_spec = raw.get("dependencySpec") or raw.get("dependency_spec")
|
|
814
|
+
if (
|
|
815
|
+
isinstance(digest, str)
|
|
816
|
+
and re.fullmatch(r"sha256:[0-9a-f]{64}", digest)
|
|
817
|
+
and kind in {"directory", "tgz"}
|
|
818
|
+
and isinstance(artifact, str)
|
|
819
|
+
and isinstance(dependency_spec, str)
|
|
820
|
+
):
|
|
821
|
+
sources[name] = {
|
|
822
|
+
"digest": digest,
|
|
823
|
+
"kind": kind,
|
|
824
|
+
"artifact": artifact,
|
|
825
|
+
"dependency_spec": dependency_spec,
|
|
826
|
+
}
|
|
827
|
+
else:
|
|
828
|
+
raise DshPluginMutationError("DSH local source receipt is invalid")
|
|
829
|
+
return {"order": order, "disabled": disabled, "sources": sources}
|
|
830
|
+
|
|
831
|
+
def _write_state(self, state: _ProfileState) -> None:
|
|
832
|
+
self._write_json(
|
|
833
|
+
self._profile_root / _STATE_FILE,
|
|
834
|
+
{
|
|
835
|
+
"version": 2,
|
|
836
|
+
"order": list(state["order"]),
|
|
837
|
+
"disabled": list(state["disabled"]),
|
|
838
|
+
"sources": {
|
|
839
|
+
name: {
|
|
840
|
+
"digest": receipt["digest"],
|
|
841
|
+
"kind": receipt["kind"],
|
|
842
|
+
"artifact": receipt["artifact"],
|
|
843
|
+
"dependencySpec": receipt["dependency_spec"],
|
|
844
|
+
}
|
|
845
|
+
for name, receipt in sorted(state["sources"].items())
|
|
846
|
+
},
|
|
847
|
+
},
|
|
848
|
+
)
|
|
849
|
+
|
|
850
|
+
def _write_active_bundles(
|
|
851
|
+
self,
|
|
852
|
+
manifest: dict[str, object],
|
|
853
|
+
state: _ProfileState,
|
|
854
|
+
) -> None:
|
|
855
|
+
dependencies = set(self._dependencies(manifest))
|
|
856
|
+
existing = self._bundles(manifest)
|
|
857
|
+
builtins = [name for name in existing if name not in dependencies]
|
|
858
|
+
disabled = set(state["disabled"])
|
|
859
|
+
enabled = [name for name in state["order"] if name in dependencies and name not in disabled]
|
|
860
|
+
dsh = manifest.get("dsh") if isinstance(manifest.get("dsh"), dict) else {}
|
|
861
|
+
assert isinstance(dsh, dict)
|
|
862
|
+
profile = dsh.get("profile") if isinstance(dsh.get("profile"), dict) else {}
|
|
863
|
+
assert isinstance(profile, dict)
|
|
864
|
+
profile["bundles"] = [*builtins, *enabled]
|
|
865
|
+
dsh["profile"] = profile
|
|
866
|
+
manifest["dsh"] = dsh
|
|
867
|
+
self._write_json(self._manifest_path(), manifest)
|
|
868
|
+
|
|
869
|
+
def _prepare_source(self, source: str) -> _PreparedSource:
|
|
870
|
+
value = source.strip()
|
|
871
|
+
candidate = Path(value).expanduser()
|
|
872
|
+
if not candidate.is_absolute() or not candidate.exists():
|
|
873
|
+
return _PreparedSource(value, "", "tgz", "")
|
|
874
|
+
local = candidate.resolve()
|
|
875
|
+
with tempfile.TemporaryDirectory(prefix="agentengine-dsh-source-") as directory:
|
|
876
|
+
if local.is_dir():
|
|
877
|
+
# Import lazily: dsh_toolchain owns the pinned pnpm workflow and
|
|
878
|
+
# imports this bridge for validation.
|
|
879
|
+
from ksadk.plugins.dsh_toolchain import ( # noqa: PLC0415
|
|
880
|
+
DshPluginDeveloper,
|
|
881
|
+
DshToolchainManager,
|
|
882
|
+
)
|
|
883
|
+
|
|
884
|
+
output = Path(directory)
|
|
885
|
+
packed = DshPluginDeveloper(toolchain=DshToolchainManager()).pack(
|
|
886
|
+
local, output_dir=output
|
|
887
|
+
)
|
|
888
|
+
archive = Path(packed.artifact)
|
|
889
|
+
kind: Literal["directory", "tgz"] = "directory"
|
|
890
|
+
elif local.is_file() and local.name.endswith(".tgz"):
|
|
891
|
+
archive = local
|
|
892
|
+
kind = "tgz"
|
|
893
|
+
else:
|
|
894
|
+
raise DshPluginMutationError(
|
|
895
|
+
"local DSH plugin source must be a directory or .tgz archive"
|
|
896
|
+
)
|
|
897
|
+
try:
|
|
898
|
+
content = archive.read_bytes()
|
|
899
|
+
except OSError as error:
|
|
900
|
+
raise DshPluginMutationError("local DSH plugin source is unreadable") from error
|
|
901
|
+
digest_hex = hashlib.sha256(content).hexdigest()
|
|
902
|
+
digest = f"sha256:{digest_hex}"
|
|
903
|
+
root = (self._dsh_home / _IMMUTABLE_SOURCE_DIR / digest_hex).resolve()
|
|
904
|
+
expected_parent = (self._dsh_home / _IMMUTABLE_SOURCE_DIR).resolve()
|
|
905
|
+
if root.parent != expected_parent:
|
|
906
|
+
raise DshPluginMutationError("immutable DSH source path escaped its store")
|
|
907
|
+
target = root / "package.tgz"
|
|
908
|
+
root.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
909
|
+
if target.exists():
|
|
910
|
+
if hashlib.sha256(target.read_bytes()).hexdigest() != digest_hex:
|
|
911
|
+
raise DshPluginMutationError(
|
|
912
|
+
"immutable DSH source store contains a digest collision"
|
|
913
|
+
)
|
|
914
|
+
else:
|
|
915
|
+
temporary = root / f".package.{os.getpid()}.tmp"
|
|
916
|
+
try:
|
|
917
|
+
temporary.write_bytes(content)
|
|
918
|
+
temporary.chmod(0o400)
|
|
919
|
+
os.replace(temporary, target)
|
|
920
|
+
finally:
|
|
921
|
+
temporary.unlink(missing_ok=True)
|
|
922
|
+
target.chmod(0o400)
|
|
923
|
+
relative = target.relative_to(self._dsh_home).as_posix()
|
|
924
|
+
return _PreparedSource(str(target), digest, kind, relative)
|
|
925
|
+
|
|
926
|
+
def _verify_source_receipts(
|
|
927
|
+
self,
|
|
928
|
+
manifest: Mapping[str, object],
|
|
929
|
+
state: _ProfileState,
|
|
930
|
+
*,
|
|
931
|
+
names: Sequence[str] | None = None,
|
|
932
|
+
) -> None:
|
|
933
|
+
dependencies = self._dependencies(manifest)
|
|
934
|
+
for name, spec in dependencies.items():
|
|
935
|
+
if _IMMUTABLE_SOURCE_DIR in spec and name not in state["sources"]:
|
|
936
|
+
raise DshPluginMutationError(
|
|
937
|
+
f"DSH plugin {name!r} is missing its immutable source receipt"
|
|
938
|
+
)
|
|
939
|
+
selected = names if names is not None else tuple(state["sources"])
|
|
940
|
+
store = (self._dsh_home / _IMMUTABLE_SOURCE_DIR).resolve()
|
|
941
|
+
for name in selected:
|
|
942
|
+
receipt = state["sources"].get(name)
|
|
943
|
+
if receipt is None:
|
|
944
|
+
continue
|
|
945
|
+
if dependencies.get(name) != receipt["dependency_spec"]:
|
|
946
|
+
raise DshPluginMutationError(
|
|
947
|
+
f"DSH plugin {name!r} dependency no longer matches its source receipt"
|
|
948
|
+
)
|
|
949
|
+
artifact = (self._dsh_home / receipt["artifact"]).resolve()
|
|
950
|
+
if not artifact.is_relative_to(store) or not artifact.is_file():
|
|
951
|
+
raise DshPluginMutationError(
|
|
952
|
+
f"DSH plugin {name!r} immutable source is unavailable"
|
|
953
|
+
)
|
|
954
|
+
try:
|
|
955
|
+
actual = f"sha256:{hashlib.sha256(artifact.read_bytes()).hexdigest()}"
|
|
956
|
+
except OSError as error:
|
|
957
|
+
raise DshPluginMutationError(
|
|
958
|
+
f"DSH plugin {name!r} immutable source is unreadable"
|
|
959
|
+
) from error
|
|
960
|
+
if actual != receipt["digest"]:
|
|
961
|
+
raise DshPluginMutationError(
|
|
962
|
+
f"DSH plugin {name!r} immutable source digest changed"
|
|
963
|
+
)
|
|
964
|
+
|
|
965
|
+
@staticmethod
|
|
966
|
+
def _write_json(path: Path, payload: object) -> None:
|
|
967
|
+
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
968
|
+
descriptor, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
|
|
969
|
+
try:
|
|
970
|
+
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
|
|
971
|
+
json.dump(payload, handle, ensure_ascii=False, indent=2)
|
|
972
|
+
handle.write("\n")
|
|
973
|
+
handle.flush()
|
|
974
|
+
os.fsync(handle.fileno())
|
|
975
|
+
os.chmod(temporary, 0o600)
|
|
976
|
+
os.replace(temporary, path)
|
|
977
|
+
finally:
|
|
978
|
+
try:
|
|
979
|
+
os.unlink(temporary)
|
|
980
|
+
except FileNotFoundError:
|
|
981
|
+
pass
|
|
982
|
+
|
|
983
|
+
def _snapshot(self) -> dict[str, bytes | None]:
|
|
984
|
+
snapshot: dict[str, bytes | None] = {}
|
|
985
|
+
for name in _SNAPSHOT_FILES:
|
|
986
|
+
path = self._profile_root / name
|
|
987
|
+
try:
|
|
988
|
+
snapshot[name] = path.read_bytes()
|
|
989
|
+
except FileNotFoundError:
|
|
990
|
+
snapshot[name] = None
|
|
991
|
+
return snapshot
|
|
992
|
+
|
|
993
|
+
def _require_package_mutation_rollback(self, *, new_profile_allowed: bool) -> None:
|
|
994
|
+
"""Reject package mutations unless their filesystem effects are reversible."""
|
|
995
|
+
|
|
996
|
+
if not self._profile_root.exists():
|
|
997
|
+
if new_profile_allowed:
|
|
998
|
+
return
|
|
999
|
+
raise DshPluginNotFoundError(f"DSH profile {self._profile!r} is not initialized")
|
|
1000
|
+
if not self._manifest_path().is_file():
|
|
1001
|
+
raise DshPluginMutationError(
|
|
1002
|
+
"existing DSH profile directory has no package manifest; refusing to mutate it"
|
|
1003
|
+
)
|
|
1004
|
+
if not (self._profile_root / "pnpm-lock.yaml").is_file():
|
|
1005
|
+
raise DshPluginMutationError(
|
|
1006
|
+
"existing DSH profile has no pnpm lockfile, so package rollback is unavailable"
|
|
1007
|
+
)
|
|
1008
|
+
|
|
1009
|
+
def _rollback(self, snapshot: Mapping[str, bytes | None], original: BaseException) -> None:
|
|
1010
|
+
try:
|
|
1011
|
+
if all(content is None for content in snapshot.values()):
|
|
1012
|
+
profiles_root = (self._dsh_home / "profiles").resolve()
|
|
1013
|
+
profile_root = self._profile_root.resolve()
|
|
1014
|
+
if profile_root.parent != profiles_root:
|
|
1015
|
+
raise DshPluginMutationError("refusing to clean an untrusted DSH profile path")
|
|
1016
|
+
if profile_root.exists():
|
|
1017
|
+
shutil.rmtree(profile_root, ignore_errors=False)
|
|
1018
|
+
return
|
|
1019
|
+
self._profile_root.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
1020
|
+
for name, content in snapshot.items():
|
|
1021
|
+
path = self._profile_root / name
|
|
1022
|
+
if content is None:
|
|
1023
|
+
path.unlink(missing_ok=True)
|
|
1024
|
+
else:
|
|
1025
|
+
path.write_bytes(content)
|
|
1026
|
+
path.chmod(0o600)
|
|
1027
|
+
if snapshot.get("pnpm-lock.yaml") is not None and self._manifest_path().is_file():
|
|
1028
|
+
self._plugin_command("install", "--frozen-lockfile")
|
|
1029
|
+
for name in ("package.json", _STATE_FILE):
|
|
1030
|
+
content = snapshot.get(name)
|
|
1031
|
+
path = self._profile_root / name
|
|
1032
|
+
if content is None:
|
|
1033
|
+
path.unlink(missing_ok=True)
|
|
1034
|
+
else:
|
|
1035
|
+
path.write_bytes(content)
|
|
1036
|
+
path.chmod(0o600)
|
|
1037
|
+
except BaseException as rollback_error:
|
|
1038
|
+
raise DshPluginMutationError(
|
|
1039
|
+
"DSH plugin mutation failed and profile rollback also failed"
|
|
1040
|
+
) from rollback_error
|
|
1041
|
+
if isinstance(original, DshBridgeError):
|
|
1042
|
+
return
|
|
1043
|
+
|
|
1044
|
+
@staticmethod
|
|
1045
|
+
def _validate_source(source: str) -> None:
|
|
1046
|
+
value = source.strip()
|
|
1047
|
+
if (
|
|
1048
|
+
not value
|
|
1049
|
+
or len(value) > 2048
|
|
1050
|
+
or value.startswith("-")
|
|
1051
|
+
or any(character in source for character in ("\r", "\n", "\0"))
|
|
1052
|
+
or value in {".", ".."}
|
|
1053
|
+
or value.startswith(("file:", "link:"))
|
|
1054
|
+
or value.startswith(("./", "../", "file:./", "file:../", "link:./", "link:../"))
|
|
1055
|
+
):
|
|
1056
|
+
raise ValueError("DSH plugin source must be a package, Git URL, or absolute local path")
|
|
1057
|
+
|
|
1058
|
+
@staticmethod
|
|
1059
|
+
def _validate_package_name(name: str) -> None:
|
|
1060
|
+
if not _PACKAGE_NAME.fullmatch(name):
|
|
1061
|
+
raise ValueError("invalid DSH plugin package name")
|
|
1062
|
+
|
|
1063
|
+
@staticmethod
|
|
1064
|
+
def _string(value: object) -> str:
|
|
1065
|
+
return value if isinstance(value, str) else ""
|
|
1066
|
+
|
|
1067
|
+
@staticmethod
|
|
1068
|
+
def _run_command(
|
|
1069
|
+
command: Sequence[str],
|
|
1070
|
+
cwd: Path,
|
|
1071
|
+
environment: Mapping[str, str],
|
|
1072
|
+
) -> _CommandResult:
|
|
1073
|
+
try:
|
|
1074
|
+
completed = subprocess.run(
|
|
1075
|
+
list(command),
|
|
1076
|
+
cwd=cwd,
|
|
1077
|
+
env=dict(environment),
|
|
1078
|
+
check=False,
|
|
1079
|
+
capture_output=True,
|
|
1080
|
+
text=True,
|
|
1081
|
+
timeout=120,
|
|
1082
|
+
)
|
|
1083
|
+
except (OSError, subprocess.TimeoutExpired) as error:
|
|
1084
|
+
raise DshHostUnavailableError("DSH host command did not complete") from error
|
|
1085
|
+
if completed.returncode != 0:
|
|
1086
|
+
diagnostic = _redact_diagnostic(completed.stderr or completed.stdout)
|
|
1087
|
+
raise DshPluginMutationError(
|
|
1088
|
+
f"DSH host command failed with exit code {completed.returncode}"
|
|
1089
|
+
+ (f": {diagnostic[-2000:]}" if diagnostic else "")
|
|
1090
|
+
)
|
|
1091
|
+
return _CommandResult(stdout=completed.stdout, stderr=completed.stderr)
|
|
1092
|
+
|
|
1093
|
+
|
|
1094
|
+
def _redact_diagnostic(value: str) -> str:
|
|
1095
|
+
value = re.sub(r"(https?://)[^/\s:@]+:[^/\s@]+@", r"\1[redacted]@", value, flags=re.I)
|
|
1096
|
+
return re.sub(
|
|
1097
|
+
r"((?:token|password|authorization|_authToken)\s*[:=]\s*)[^\s]+",
|
|
1098
|
+
r"\1[redacted]",
|
|
1099
|
+
value,
|
|
1100
|
+
flags=re.I,
|
|
1101
|
+
).strip()
|
|
1102
|
+
|
|
1103
|
+
|
|
1104
|
+
__all__ = [
|
|
1105
|
+
"DshBridgeError",
|
|
1106
|
+
"DshBridgeHost",
|
|
1107
|
+
"DshClientBundle",
|
|
1108
|
+
"DshHostUnavailableError",
|
|
1109
|
+
"DshPluginApprovalRequired",
|
|
1110
|
+
"DshPluginInventory",
|
|
1111
|
+
"DshPluginMutationError",
|
|
1112
|
+
"DshPluginNotFoundError",
|
|
1113
|
+
"DshProfilePluginBridge",
|
|
1114
|
+
"DshProfileProjection",
|
|
1115
|
+
"dsh_subprocess_environment",
|
|
1116
|
+
]
|