From c18bea337410de89468fc11f88b4a27a17432fcd Mon Sep 17 00:00:00 2001 From: Snider Date: Fri, 1 May 2026 08:35:40 +0100 Subject: [PATCH 01/19] chore: add EUPL-1.2 LICENCE file (UK English canonical) Reference: core/api/LICENCE. Co-Authored-By: Cladius Maximus --- LICENCE | 287 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 287 insertions(+) create mode 100644 LICENCE diff --git a/LICENCE b/LICENCE new file mode 100644 index 0000000..4153cd3 --- /dev/null +++ b/LICENCE @@ -0,0 +1,287 @@ + EUROPEAN UNION PUBLIC LICENCE v. 1.2 + EUPL © the European Union 2007, 2016 + +This European Union Public Licence (the ‘EUPL’) applies to the Work (as defined +below) which is provided under the terms of this Licence. Any use of the Work, +other than as authorised under this Licence is prohibited (to the extent such +use is covered by a right of the copyright holder of the Work). + +The Work is provided under the terms of this Licence when the Licensor (as +defined below) has placed the following notice immediately following the +copyright notice for the Work: + + Licensed under the EUPL + +or has expressed by any other means his willingness to license under the EUPL. + +1. Definitions + +In this Licence, the following terms have the following meaning: + +- ‘The Licence’: this Licence. + +- ‘The Original Work’: the work or software distributed or communicated by the + Licensor under this Licence, available as Source Code and also as Executable + Code as the case may be. + +- ‘Derivative Works’: the works or software that could be created by the + Licensee, based upon the Original Work or modifications thereof. This Licence + does not define the extent of modification or dependence on the Original Work + required in order to classify a work as a Derivative Work; this extent is + determined by copyright law applicable in the country mentioned in Article 15. + +- ‘The Work’: the Original Work or its Derivative Works. + +- ‘The Source Code’: the human-readable form of the Work which is the most + convenient for people to study and modify. + +- ‘The Executable Code’: any code which has generally been compiled and which is + meant to be interpreted by a computer as a program. + +- ‘The Licensor’: the natural or legal person that distributes or communicates + the Work under the Licence. + +- ‘Contributor(s)’: any natural or legal person who modifies the Work under the + Licence, or otherwise contributes to the creation of a Derivative Work. + +- ‘The Licensee’ or ‘You’: any natural or legal person who makes any usage of + the Work under the terms of the Licence. + +- ‘Distribution’ or ‘Communication’: any act of selling, giving, lending, + renting, distributing, communicating, transmitting, or otherwise making + available, online or offline, copies of the Work or providing access to its + essential functionalities at the disposal of any other natural or legal + person. + +2. Scope of the rights granted by the Licence + +The Licensor hereby grants You a worldwide, royalty-free, non-exclusive, +sublicensable licence to do the following, for the duration of copyright vested +in the Original Work: + +- use the Work in any circumstance and for all usage, +- reproduce the Work, +- modify the Work, and make Derivative Works based upon the Work, +- communicate to the public, including the right to make available or display + the Work or copies thereof to the public and perform publicly, as the case may + be, the Work, +- distribute the Work or copies thereof, +- lend and rent the Work or copies thereof, +- sublicense rights in the Work or copies thereof. + +Those rights can be exercised on any media, supports and formats, whether now +known or later invented, as far as the applicable law permits so. + +In the countries where moral rights apply, the Licensor waives his right to +exercise his moral right to the extent allowed by law in order to make effective +the licence of the economic rights here above listed. + +The Licensor grants to the Licensee royalty-free, non-exclusive usage rights to +any patents held by the Licensor, to the extent necessary to make use of the +rights granted on the Work under this Licence. + +3. Communication of the Source Code + +The Licensor may provide the Work either in its Source Code form, or as +Executable Code. If the Work is provided as Executable Code, the Licensor +provides in addition a machine-readable copy of the Source Code of the Work +along with each copy of the Work that the Licensor distributes or indicates, in +a notice following the copyright notice attached to the Work, a repository where +the Source Code is easily and freely accessible for as long as the Licensor +continues to distribute or communicate the Work. + +4. Limitations on copyright + +Nothing in this Licence is intended to deprive the Licensee of the benefits from +any exception or limitation to the exclusive rights of the rights owners in the +Work, of the exhaustion of those rights or of other applicable limitations +thereto. + +5. Obligations of the Licensee + +The grant of the rights mentioned above is subject to some restrictions and +obligations imposed on the Licensee. Those obligations are the following: + +Attribution right: The Licensee shall keep intact all copyright, patent or +trademarks notices and all notices that refer to the Licence and to the +disclaimer of warranties. The Licensee must include a copy of such notices and a +copy of the Licence with every copy of the Work he/she distributes or +communicates. The Licensee must cause any Derivative Work to carry prominent +notices stating that the Work has been modified and the date of modification. + +Copyleft clause: If the Licensee distributes or communicates copies of the +Original Works or Derivative Works, this Distribution or Communication will be +done under the terms of this Licence or of a later version of this Licence +unless the Original Work is expressly distributed only under this version of the +Licence — for example by communicating ‘EUPL v. 1.2 only’. The Licensee +(becoming Licensor) cannot offer or impose any additional terms or conditions on +the Work or Derivative Work that alter or restrict the terms of the Licence. + +Compatibility clause: If the Licensee Distributes or Communicates Derivative +Works or copies thereof based upon both the Work and another work licensed under +a Compatible Licence, this Distribution or Communication can be done under the +terms of this Compatible Licence. For the sake of this clause, ‘Compatible +Licence’ refers to the licences listed in the appendix attached to this Licence. +Should the Licensee's obligations under the Compatible Licence conflict with +his/her obligations under this Licence, the obligations of the Compatible +Licence shall prevail. + +Provision of Source Code: When distributing or communicating copies of the Work, +the Licensee will provide a machine-readable copy of the Source Code or indicate +a repository where this Source will be easily and freely available for as long +as the Licensee continues to distribute or communicate the Work. + +Legal Protection: This Licence does not grant permission to use the trade names, +trademarks, service marks, or names of the Licensor, except as required for +reasonable and customary use in describing the origin of the Work and +reproducing the content of the copyright notice. + +6. Chain of Authorship + +The original Licensor warrants that the copyright in the Original Work granted +hereunder is owned by him/her or licensed to him/her and that he/she has the +power and authority to grant the Licence. + +Each Contributor warrants that the copyright in the modifications he/she brings +to the Work are owned by him/her or licensed to him/her and that he/she has the +power and authority to grant the Licence. + +Each time You accept the Licence, the original Licensor and subsequent +Contributors grant You a licence to their contributions to the Work, under the +terms of this Licence. + +7. Disclaimer of Warranty + +The Work is a work in progress, which is continuously improved by numerous +Contributors. It is not a finished work and may therefore contain defects or +‘bugs’ inherent to this type of development. + +For the above reason, the Work is provided under the Licence on an ‘as is’ basis +and without warranties of any kind concerning the Work, including without +limitation merchantability, fitness for a particular purpose, absence of defects +or errors, accuracy, non-infringement of intellectual property rights other than +copyright as stated in Article 6 of this Licence. + +This disclaimer of warranty is an essential part of the Licence and a condition +for the grant of any rights to the Work. + +8. Disclaimer of Liability + +Except in the cases of wilful misconduct or damages directly caused to natural +persons, the Licensor will in no event be liable for any direct or indirect, +material or moral, damages of any kind, arising out of the Licence or of the use +of the Work, including without limitation, damages for loss of goodwill, work +stoppage, computer failure or malfunction, loss of data or any commercial +damage, even if the Licensor has been advised of the possibility of such damage. +However, the Licensor will be liable under statutory product liability laws as +far such laws apply to the Work. + +9. Additional agreements + +While distributing the Work, You may choose to conclude an additional agreement, +defining obligations or services consistent with this Licence. However, if +accepting obligations, You may act only on your own behalf and on your sole +responsibility, not on behalf of the original Licensor or any other Contributor, +and only if You agree to indemnify, defend, and hold each Contributor harmless +for any liability incurred by, or claims asserted against such Contributor by +the fact You have accepted any warranty or additional liability. + +10. Acceptance of the Licence + +The provisions of this Licence can be accepted by clicking on an icon ‘I agree’ +placed under the bottom of a window displaying the text of this Licence or by +affirming consent in any other similar way, in accordance with the rules of +applicable law. Clicking on that icon indicates your clear and irrevocable +acceptance of this Licence and all of its terms and conditions. + +Similarly, you irrevocably accept this Licence and all of its terms and +conditions by exercising any rights granted to You by Article 2 of this Licence, +such as the use of the Work, the creation by You of a Derivative Work or the +Distribution or Communication by You of the Work or copies thereof. + +11. Information to the public + +In case of any Distribution or Communication of the Work by means of electronic +communication by You (for example, by offering to download the Work from a +remote location) the distribution channel or media (for example, a website) must +at least provide to the public the information requested by the applicable law +regarding the Licensor, the Licence and the way it may be accessible, concluded, +stored and reproduced by the Licensee. + +12. Termination of the Licence + +The Licence and the rights granted hereunder will terminate automatically upon +any breach by the Licensee of the terms of the Licence. + +Such a termination will not terminate the licences of any person who has +received the Work from the Licensee under the Licence, provided such persons +remain in full compliance with the Licence. + +13. Miscellaneous + +Without prejudice of Article 9 above, the Licence represents the complete +agreement between the Parties as to the Work. + +If any provision of the Licence is invalid or unenforceable under applicable +law, this will not affect the validity or enforceability of the Licence as a +whole. Such provision will be construed or reformed so as necessary to make it +valid and enforceable. + +The European Commission may publish other linguistic versions or new versions of +this Licence or updated versions of the Appendix, so far this is required and +reasonable, without reducing the scope of the rights granted by the Licence. New +versions of the Licence will be published with a unique version number. + +All linguistic versions of this Licence, approved by the European Commission, +have identical value. Parties can take advantage of the linguistic version of +their choice. + +14. Jurisdiction + +Without prejudice to specific agreement between parties, + +- any litigation resulting from the interpretation of this License, arising + between the European Union institutions, bodies, offices or agencies, as a + Licensor, and any Licensee, will be subject to the jurisdiction of the Court + of Justice of the European Union, as laid down in article 272 of the Treaty on + the Functioning of the European Union, + +- any litigation arising between other parties and resulting from the + interpretation of this License, will be subject to the exclusive jurisdiction + of the competent court where the Licensor resides or conducts its primary + business. + +15. Applicable Law + +Without prejudice to specific agreement between parties, + +- this Licence shall be governed by the law of the European Union Member State + where the Licensor has his seat, resides or has his registered office, + +- this licence shall be governed by Belgian law if the Licensor has no seat, + residence or registered office inside a European Union Member State. + +Appendix + +‘Compatible Licences’ according to Article 5 EUPL are: + +- GNU General Public License (GPL) v. 2, v. 3 +- GNU Affero General Public License (AGPL) v. 3 +- Open Software License (OSL) v. 2.1, v. 3.0 +- Eclipse Public License (EPL) v. 1.0 +- CeCILL v. 2.0, v. 2.1 +- Mozilla Public Licence (MPL) v. 2 +- GNU Lesser General Public Licence (LGPL) v. 2.1, v. 3 +- Creative Commons Attribution-ShareAlike v. 3.0 Unported (CC BY-SA 3.0) for + works other than software +- European Union Public Licence (EUPL) v. 1.1, v. 1.2 +- Québec Free and Open-Source Licence — Reciprocity (LiLiQ-R) or Strong + Reciprocity (LiLiQ-R+). + +The European Commission may update this Appendix to later versions of the above +licences without producing a new version of the EUPL, as long as they provide +the rights granted in Article 2 of this Licence and protect the covered Source +Code from exclusive appropriation. + +All other changes or additions to this Appendix require the production of a new +EUPL version. From 7c2ce0f1f8fa8f77de00024d7d3e633e206055ad Mon Sep 17 00:00:00 2001 From: Snider Date: Fri, 1 May 2026 09:42:20 +0100 Subject: [PATCH 02/19] chore(repo): refresh submodules + go.work hygiene (Phase 2 cascade unblock) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - git submodule update on external/* to current dev tips - go.work paths fixed for Phase 1 /go/ subtree layout where stale - go.work go-version bumped 1.26.0 → 1.26.2 to match submodule floor Workspace-mode build (`go build ./...`) is the verification path. Some repos may surface transitive dep issues (api/go.sum checksum drift, etc.) which are separate cascade tickets — not blocking this metadata refresh. Co-Authored-By: Cladius Maximus --- external/go | 2 +- external/io | 2 +- external/log | 2 +- external/process | 2 +- external/rag | 2 +- external/ws | 2 +- go.work | 10 +++++----- 7 files changed, 11 insertions(+), 11 deletions(-) diff --git a/external/go b/external/go index d661b70..b48b896 160000 --- a/external/go +++ b/external/go @@ -1 +1 @@ -Subproject commit d661b703e16183b3cbab101de189f688888a1174 +Subproject commit b48b896b1e6216e95c8f1dfc6490b1763eedd8fb diff --git a/external/io b/external/io index 789653d..871556d 160000 --- a/external/io +++ b/external/io @@ -1 +1 @@ -Subproject commit 789653dfc376383a3873993cdb875c8c717e4b05 +Subproject commit 871556d314a244c9d866a32a67964670d8ee50d2 diff --git a/external/log b/external/log index df05298..a5e20d5 160000 --- a/external/log +++ b/external/log @@ -1 +1 @@ -Subproject commit df0529839b2ab786a6a3da374fa664867d5f9f09 +Subproject commit a5e20d5dfab13b781d6309eab9edbb99839aca43 diff --git a/external/process b/external/process index a0ad5cb..ee29b19 160000 --- a/external/process +++ b/external/process @@ -1 +1 @@ -Subproject commit a0ad5cbdea96ba43e86bceb1fa8c0b07d0343b3f +Subproject commit ee29b19f381c1e94f0068e33dc13a50c17c9e228 diff --git a/external/rag b/external/rag index 8253303..9c1da73 160000 --- a/external/rag +++ b/external/rag @@ -1 +1 @@ -Subproject commit 825330379dae0b6be1597ac8d92f8db2624038e2 +Subproject commit 9c1da737336b08f9a3bfbbd520ae7e72c8c5d598 diff --git a/external/ws b/external/ws index c83f7a1..3c7486e 160000 --- a/external/ws +++ b/external/ws @@ -1 +1 @@ -Subproject commit c83f7a1d91c314543ac0d61d14a13b24877b8cd7 +Subproject commit 3c7486edd4cf131c0b3930dd1303096c3d134d4e diff --git a/go.work b/go.work index 7942ff9..bc9b4ad 100644 --- a/go.work +++ b/go.work @@ -6,9 +6,9 @@ go 1.26.2 use ( ./go ./external/go - ./external/io - ./external/log - ./external/process - ./external/rag - ./external/ws + ./external/io/go + ./external/log/go + ./external/process/go + ./external/rag/go + ./external/ws/go ) From adc934cffbd34e0cb2c6e22d9c6788a8ef16cdab Mon Sep 17 00:00:00 2001 From: Snider Date: Fri, 1 May 2026 10:35:09 +0100 Subject: [PATCH 03/19] chore(repo): untrack build artifacts + scan caches (audit dim tracked-artifacts) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes from git index: node_modules.bak/, .lintdeps/, .scannerwork/, .DS_Store, dist/, etc. — regenerable build/scan outputs that should never be tracked. Updates .gitignore with the canonical pattern set. Audit dimension `tracked-artifacts` (core/go commit 62aac07) flagged 2 entries. Same root-cause class as Mantis #1333 (gui ui/node_modules.bak/) — applying the structural fix ecosystem-wide. Co-Authored-By: Cladius Maximus --- .gitignore | 11 +++++++++++ .scannerwork/.sonar_lock | 0 .scannerwork/report-task.txt | 6 ------ 3 files changed, 11 insertions(+), 6 deletions(-) delete mode 100644 .scannerwork/.sonar_lock delete mode 100644 .scannerwork/report-task.txt diff --git a/.gitignore b/.gitignore index f52aea0..27f1406 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,14 @@ .core/ .idea/ core-mcp +.DS_Store +.lintdeps/ +.scannerwork/ +node_modules/ +node_modules.bak/ +dist/ +__pycache__/ +*.pyc +coverage/ +htmlcov/ +.coverage diff --git a/.scannerwork/.sonar_lock b/.scannerwork/.sonar_lock deleted file mode 100644 index e69de29..0000000 diff --git a/.scannerwork/report-task.txt b/.scannerwork/report-task.txt deleted file mode 100644 index 6a709c3..0000000 --- a/.scannerwork/report-task.txt +++ /dev/null @@ -1,6 +0,0 @@ -projectKey=core-mcp -serverUrl=https://sonar.lthn.sh -serverVersion=26.4.0.121862 -dashboardUrl=https://sonar.lthn.sh/dashboard?id=core-mcp -ceTaskId=ddc1ef62-cfd8-4a66-a719-29cad1fea133 -ceTaskUrl=https://sonar.lthn.sh/api/ce/task?id=ddc1ef62-cfd8-4a66-a719-29cad1fea133 From f8e8e1d7719549960568c3462d3f56bbeb759d33 Mon Sep 17 00:00:00 2001 From: Snider Date: Fri, 1 May 2026 16:26:31 +0100 Subject: [PATCH 04/19] =?UTF-8?q?refactor(mcp):=20rename=20register.go=20?= =?UTF-8?q?=E2=86=92=20service.go=20+=20add=20NewService=20factory=20(Mant?= =?UTF-8?q?is=20#1336)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mcp already had Register + OnStartup + OnShutdown + HandleIPCEvents in register.go — canonical lifecycle was complete. Two changes: 1. Renamed register.go → service.go for canon-form alignment with the rest of the ecosystem (go-store, go-io, go-log, go-process, etc.). 2. Added NewService(Options) factory alongside the existing Register. Register stays the discovery-based path (collects subsystems from already-registered services); NewService is the explicit-options path for callers that own construction order. Behaviour unchanged. Both shapes available per Snider 2026-05-01. --- go.work.sum | 292 +++++++++++++++++++++++++ go/pkg/mcp/{register.go => service.go} | 31 ++- 2 files changed, 322 insertions(+), 1 deletion(-) create mode 100644 go.work.sum rename go/pkg/mcp/{register.go => service.go} (81%) diff --git a/go.work.sum b/go.work.sum new file mode 100644 index 0000000..4686a67 --- /dev/null +++ b/go.work.sum @@ -0,0 +1,292 @@ +cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= +cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= +cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= +cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= +codeberg.org/go-fonts/liberation v0.5.0 h1:SsKoMO1v1OZmzkG2DY+7ZkCL9U+rrWI09niOLfQ5Bo0= +codeberg.org/go-fonts/liberation v0.5.0/go.mod h1:zS/2e1354/mJ4pGzIIaEtm/59VFCFnYC7YV6YdGl5GU= +codeberg.org/go-latex/latex v0.1.0 h1:hoGO86rIbWVyjtlDLzCqZPjNykpWQ9YuTZqAzPcfL3c= +codeberg.org/go-latex/latex v0.1.0/go.mod h1:LA0q/AyWIYrqVd+A9Upkgsb+IqPcmSTKc9Dny04MHMw= +codeberg.org/go-pdf/fpdf v0.10.0 h1:u+w669foDDx5Ds43mpiiayp40Ov6sZalgcPMDBcZRd4= +codeberg.org/go-pdf/fpdf v0.10.0/go.mod h1:Y0DGRAdZ0OmnZPvjbMp/1bYxmIPxm0ws4tfoPOc4LjU= +cyphar.com/go-pathrs v0.2.1 h1:9nx1vOgwVvX1mNBWDu93+vaceedpbsDqo+XuBGL40b8= +cyphar.com/go-pathrs v0.2.1/go.mod h1:y8f1EMG7r+hCuFf/rXsKqMJrJAUoADZGNh5/vZPKcGc= +git.sr.ht/~sbinet/gg v0.6.0 h1:RIzgkizAk+9r7uPzf/VfbJHBMKUr0F5hRFxTUGMnt38= +git.sr.ht/~sbinet/gg v0.6.0/go.mod h1:uucygbfC9wVPQIfrmwM2et0imr8L7KQWywX0xpFMm94= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0 h1:sBEjpZlNHzK1voKq9695PJSX2o5NEXl7/OL3coiIY0c= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0= +github.com/TheTitanrain/w32 v0.0.0-20180517000239-4f5cfb03fabf h1:FPsprx82rdrX2jiKyS17BH6IrTmUBYqZa/CXT4uvb+I= +github.com/TheTitanrain/w32 v0.0.0-20180517000239-4f5cfb03fabf/go.mod h1:peYoMncQljjNS6tZwI9WVyQB3qZS6u79/N3mBOcnd3I= +github.com/agnivade/levenshtein v1.1.1 h1:QY8M92nrzkmr798gCo3kmMyqXFzdQVpxLlGPRBij0P8= +github.com/agnivade/levenshtein v1.1.1/go.mod h1:veldBMzWxcCG2ZvUTKD2kJNRdCk5hVbJomOvKkmgYbo= +github.com/ajstarks/svgo v0.0.0-20211024235047-1546f124cd8b h1:slYM766cy2nI3BwyRiyQj/Ud48djTMtMebDqepE95rw= +github.com/ajstarks/svgo v0.0.0-20211024235047-1546f124cd8b/go.mod h1:1KcenG0jGWcpt8ov532z81sp/kMMUG485J2InIOyADM= +github.com/apache/arrow/go/arrow v0.0.0-20211112161151-bc219186db40 h1:q4dksr6ICHXqG5hm0ZW5IHyeEJXoIJSOZeBLmWPNeIQ= +github.com/apache/arrow/go/arrow v0.0.0-20211112161151-bc219186db40/go.mod h1:Q7yQnSMnLvcXlZ8RV+jwz/6y1rQTqbX6C82SndT52Zs= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY= +github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0= +github.com/bwesterb/go-ristretto v1.2.3 h1:1w53tCkGhCQ5djbat3+MH0BAQ5Kfgbt56UZQ/JMzngw= +github.com/bwesterb/go-ristretto v1.2.3/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0= +github.com/campoy/embedmd v1.0.0 h1:V4kI2qTJJLf4J29RzI/MAt2c3Bl4dQSYPuflzwFH2hY= +github.com/campoy/embedmd v1.0.0/go.mod h1:oxyr9RCiSXg0M3VJ3ks0UGfp98BpSSGr0kpiX3MzVl8= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= +github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/chewxy/hm v1.0.0 h1:zy/TSv3LV2nD3dwUEQL2VhXeoXbb9QkpmdRAVUFiA6k= +github.com/chewxy/hm v1.0.0/go.mod h1:qg9YI4q6Fkj/whwHR1D+bOGeF7SniIP40VweVepLjg0= +github.com/chewxy/math32 v1.11.0 h1:8sek2JWqeaKkVnHa7bPVqCEOUPbARo4SGxs6toKyAOo= +github.com/chewxy/math32 v1.11.0/go.mod h1:dOB2rcuFrCn6UHrze36WSLVPKtzPMRAQvBvUwkSsLqs= +github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg= +github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= +github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 h1:6xNmx7iTtyBRev0+D/Tv1FZd4SCg8axKApyNyRsAt/w= +github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI= +github.com/containerd/console v1.0.3 h1:lIr7SlA5PxZyMV30bDW0MGbiOPXwc63yRuCP0ARubLw= +github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= +github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= +github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= +github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= +github.com/d4l3k/go-bfloat16 v0.0.0-20211005043715-690c3bdd05f1 h1:cBzrdJPAFBsgCrDPnZxlp1dF2+k4r1kVpD7+1S1PVjY= +github.com/d4l3k/go-bfloat16 v0.0.0-20211005043715-690c3bdd05f1/go.mod h1:uw2gLcxEuYUlAd/EXyjc/v55nd3+47YAgWbSXVxPrNI= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo= +github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM= +github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= +github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A= +github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/emirpasic/gods/v2 v2.0.0-alpha h1:dwFlh8pBg1VMOXWGipNMRt8v96dKAIvBehtCt6OtunU= +github.com/emirpasic/gods/v2 v2.0.0-alpha/go.mod h1:W0y4M2dtBB9U5z3YlghmpuUhiaZT2h6yoeE+C1sCp6A= +github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA= +github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU= +github.com/envoyproxy/go-control-plane/envoy v1.36.0 h1:yg/JjO5E7ubRyKX3m07GF3reDNEnfOboJ0QySbH736g= +github.com/envoyproxy/go-control-plane/envoy v1.36.0/go.mod h1:ty89S1YCCVruQAm9OtKeEkQLTb+Lkz0k8v9W0Oxsv98= +github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an9lx6VBE2cnb8wp1vEGNYGI= +github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4= +github.com/envoyproxy/protoc-gen-validate v1.3.0 h1:TvGH1wof4H33rezVKWSpqKz5NXWg5VPuZ0uONDT6eb4= +github.com/envoyproxy/protoc-gen-validate v1.3.0/go.mod h1:HvYl7zwPa5mffgyeTUHA9zHIH36nmrm7oCbo4YKoSWA= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= +github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/gin-contrib/cors v1.7.2 h1:oLDHxdg8W/XDoN/8zamqk/Drgt4oVZDvaV0YmvVICQw= +github.com/gin-contrib/cors v1.7.2/go.mod h1:SUJVARKgQ40dmrzgXEVxj2m7Ig1v1qIboQkPDTQ9t2E= +github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs= +github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= +github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= +github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= +github.com/goccmack/gocc v1.0.2 h1:PHv20lcM1Erz+kovS+c07DnDFp6X5cvghndtTXuEyfE= +github.com/goccmack/gocc v1.0.2/go.mod h1:LXX2tFVUggS/Zgx/ICPOr3MLyusuM7EcbfkPvNsjdO8= +github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= +github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g= +github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= +github.com/golang/glog v1.2.5 h1:DrW6hGnjIhtvhOIiAKT6Psh/Kd/ldepEa81DKeiRJ5I= +github.com/golang/glog v1.2.5/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/google/flatbuffers v24.3.25+incompatible h1:CX395cjN9Kke9mmalRoL3d81AtFUxJM+yDthflgJGkI= +github.com/google/flatbuffers v24.3.25+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-github/v39 v39.2.0 h1:rNNM311XtPOz5rDdsJXAp2o8F67X9FnROXTvto3aSnQ= +github.com/google/go-github/v39 v39.2.0/go.mod h1:C1s8C5aCC9L+JXIYpJM5GYytdX52vC1bLvHEF1IhBrE= +github.com/google/gofuzz v1.0.0 h1:A8PeW59pxE9IoFRqBp37U+mSNaQoZ46F1f0f863XSXw= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 h1:njuLRcjAuMKr7kI3D85AXWkw6/+v9PwtV6M6o11sWHQ= +github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs= +github.com/jordanlewis/gcassert v0.0.0-20250430164644-389ef753e22e h1:a+PGEeXb+exwBS3NboqXHyxarD9kaboBbrSp+7GuBuc= +github.com/jordanlewis/gcassert v0.0.0-20250430164644-389ef753e22e/go.mod h1:ZybsQk6DWyN5t7An1MuPm1gtSZ1xDaTXS9ZjIOxvQrk= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/k0kubun/go-ansi v0.0.0-20180517002512-3bf9e2903213 h1:qGQQKEcAR99REcMpsXCp3lJ03zYT1PkRd3kQGPn9GVg= +github.com/k0kubun/go-ansi v0.0.0-20180517002512-3bf9e2903213/go.mod h1:vNUNkEQ1e29fT/6vq2aBdFsgNPmy8qMdSay1npru+Sw= +github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c= +github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/kr/pty v1.1.1 h1:VkoXIwSboBpnk99O/KFauAEILuNHv5DVFKZMBN/gUgw= +github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY= +github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g= +github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= +github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= +github.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed1YDKpEz01A= +github.com/leaanthony/go-ansi-parser v1.6.1/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU= +github.com/leaanthony/gosod v1.0.4 h1:YLAbVyd591MRffDgxUOU1NwLhT9T1/YiwjKZpkNFeaI= +github.com/leaanthony/gosod v1.0.4/go.mod h1:GKuIL0zzPj3O1SdWQOdgURSuhkF+Urizzxh26t9f1cw= +github.com/leaanthony/slicer v1.6.0 h1:1RFP5uiPJvT93TAHi+ipd3NACobkW53yUiBqZheE/Js= +github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8= +github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M= +github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 h1:PwQumkgq4/acIiZhtifTV5OUqqiP82UAl0h87xj/l9k= +github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= +github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= +github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-pointer v0.0.1 h1:n+XhsuGeVO6MEAp7xyEukFINEa+Quek5psIR/ylA6o0= +github.com/mattn/go-pointer v0.0.1/go.mod h1:2zXcozF6qYGgmsG+SeTZz3oAbFLdD3OWqnUbNvJZAlc= +github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM= +github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8= +github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU= +github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk= +github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= +github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= +github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= +github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs= +github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= +github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= +github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= +github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= +github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= +github.com/morikuni/aec v1.1.0 h1:vBBl0pUnvi/Je71dsRrhMBtreIqNMYErSAbEeb8jrXQ= +github.com/morikuni/aec v1.1.0/go.mod h1:xDRgiq/iw5l+zkao76YTKzKttOp2cwPEne25HDkJnBw= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/nlpodyssey/gopickle v0.3.0 h1:BLUE5gxFLyyNOPzlXxt6GoHEMMxD0qhsE4p0CIQyoLw= +github.com/nlpodyssey/gopickle v0.3.0/go.mod h1:f070HJ/yR+eLi5WmM1OXJEGaTpuJEUiib19olXgYha0= +github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= +github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/pdevine/tensor v0.0.0-20240510204454-f88f4562727c h1:GwiUUjKefgvSNmv3NCvI/BL0kDebW6Xa+kcdpdc1mTY= +github.com/pdevine/tensor v0.0.0-20240510204454-f88f4562727c/go.mod h1:PSojXDXF7TbgQiD6kkd98IHOS0QqTyUEaWRiS8+BLu8= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/samber/lo v1.52.0 h1:Rvi+3BFHES3A8meP33VPAxiBZX/Aws5RxrschYGjomw= +github.com/samber/lo v1.52.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0= +github.com/shirou/gopsutil/v4 v4.26.1 h1:TOkEyriIXk2HX9d4isZJtbjXbEjf5qyKPAzbzY0JWSo= +github.com/shirou/gopsutil/v4 v4.26.1/go.mod h1:medLI9/UNAb0dOI9Q3/7yWSqKkj00u+1tgY8nvv41pc= +github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= +github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo= +github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/testcontainers/testcontainers-go v0.40.0 h1:pSdJYLOVgLE8YdUY2FHQ1Fxu+aMnb6JfVz1mxk7OeMU= +github.com/testcontainers/testcontainers-go v0.40.0/go.mod h1:FSXV5KQtX2HAMlm7U3APNyLkkap35zNLxukw9oBi/MY= +github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA= +github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI= +github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw= +github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ= +github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ= +github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4= +github.com/tkrajina/typescriptify-golang-structs v0.2.0 h1:ZedWk82egydDspGTryAatbX0/1NZDQbdiZLoCbOk4f8= +github.com/tkrajina/typescriptify-golang-structs v0.2.0/go.mod h1:sjU00nti/PMEOZb07KljFlR+lJ+RotsC0GBQMv9EKls= +github.com/tree-sitter/go-tree-sitter v0.25.0 h1:sx6kcg8raRFCvc9BnXglke6axya12krCJF5xJ2sftRU= +github.com/tree-sitter/go-tree-sitter v0.25.0/go.mod h1:r77ig7BikoZhHrrsjAnv8RqGti5rtSyvDHPzgTPsUuU= +github.com/tree-sitter/tree-sitter-cpp v0.23.4 h1:LaWZsiqQKvR65yHgKmnaqA+uz6tlDJTJFCyFIeZU/8w= +github.com/tree-sitter/tree-sitter-cpp v0.23.4/go.mod h1:doqNW64BriC7WBCQ1klf0KmJpdEvfxyXtoEybnBo6v8= +github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY= +github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= +github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= +github.com/wailsapp/go-webview2 v1.0.23 h1:jmv8qhz1lHibCc79bMM/a/FqOnnzOGEisLav+a0b9P0= +github.com/wailsapp/go-webview2 v1.0.23/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc= +github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs= +github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o= +github.com/wailsapp/wails/v2 v2.11.0 h1:seLacV8pqupq32IjS4Y7V8ucab0WZwtK6VvUVxSBtqQ= +github.com/wailsapp/wails/v2 v2.11.0/go.mod h1:jrf0ZaM6+GBc1wRmXsM8cIvzlg0karYin3erahI4+0k= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= +github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= +github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs= +github.com/xdg-go/scram v1.2.0/go.mod h1:3dlrS0iBaWKYVt2ZfA4cj48umJZ+cAEbR6/SjLA88I8= +github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8= +github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +github.com/xtgo/set v1.0.0 h1:6BCNBRv3ORNDQ7fyoJXRv+tstJz3m1JVFQErfeZz2pY= +github.com/xtgo/set v1.0.0/go.mod h1:d3NHzGzSa0NmB2NhFyECA+QdRp29oEn2xbT+TpeFoM8= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= +github.com/yuin/goldmark v1.4.13 h1:fVcFKWvrslecOb/tg+Cc05dkeYx540o0FuFt3nUVDoE= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= +github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= +go.opentelemetry.io/contrib/detectors/gcp v1.39.0 h1:kWRNZMsfBHZ+uHjiH4y7Etn2FK26LAGkNFw7RHv1DhE= +go.opentelemetry.io/contrib/detectors/gcp v1.39.0/go.mod h1:t/OGqzHBa5v6RHZwrDBJ2OirWc+4q/w2fTbLZwAKjTk= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 h1:7iP2uCb7sGddAr30RRS6xjKy7AZ2JtTOPA3oolgVSw8= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0/go.mod h1:c7hN3ddxs/z6q9xwvfLPk+UHlWRQyaeR1LdgfL/66l0= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0 h1:wVZXIWjQSeSmMoxF74LzAnpVQOAFDo3pPji9Y4SOFKc= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0/go.mod h1:khvBS2IggMFNwZK/6lEeHg/W57h/IX6J4URh57fuI40= +go4.org/unsafe/assume-no-moving-gc v0.0.0-20231121144256-b99613f794b6 h1:lGdhQUN/cnWdSH3291CUuxSEqc+AsGTiDxPP3r2J0l4= +go4.org/unsafe/assume-no-moving-gc v0.0.0-20231121144256-b99613f794b6/go.mod h1:FftLjUGFEDu5k8lt0ddY+HcrH/qU/0qk+H8j9/nTl3E= +golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ= +golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs= +golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c h1:6a8FdnNk6bTXBjR4AGKFgUKuo+7GnR3FX5L7CbveeZc= +golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c/go.mod h1:TpUTTEp9frx7rTdLpC9gFG9kdI7zVLFTFFlqaH2Cncw= +golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gonum.org/v1/plot v0.15.2 h1:Tlfh/jBk2tqjLZ4/P8ZIwGrLEWQSPDLRm/SNWKNXiGI= +gonum.org/v1/plot v0.15.2/go.mod h1:DX+x+DWso3LTha+AdkJEv5Txvi+Tql3KAGkehP0/Ubg= +gonum.org/v1/tools v0.0.0-20200318103217-c168b003ce8c h1:cJWOvXtcaFSGXz2F4z2AMM0VV7edDDGrxb5GLQH7ayQ= +gonum.org/v1/tools v0.0.0-20200318103217-c168b003ce8c/go.mod h1:fy6Otjqbk477ELp8IXTpw1cObQtLbRCBVonY+bTTfcM= +google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls= +google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gorgonia.org/vecf32 v0.9.0 h1:PClazic1r+JVJ1dEzRXgeiVl4g1/Hf/w+wUSqnco1Xg= +gorgonia.org/vecf32 v0.9.0/go.mod h1:NCc+5D2oxddRL11hd+pCB1PEyXWOyiQxfZ/1wwhOXCA= +gorgonia.org/vecf64 v0.9.0 h1:bgZDP5x0OzBF64PjMGC3EvTdOoMEcmfAh1VCUnZFm1A= +gorgonia.org/vecf64 v0.9.0/go.mod h1:hp7IOWCnRiVQKON73kkC/AUMtEXyf9kGlVrtPQ9ccVA= +rsc.io/pdf v0.1.1 h1:k1MczvYDUvJBe93bYd7wrZLLUEcLZAuF824/I4e5Xr4= +rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/go/pkg/mcp/register.go b/go/pkg/mcp/service.go similarity index 81% rename from go/pkg/mcp/register.go rename to go/pkg/mcp/service.go index f4bc4c4..b85dcc7 100644 --- a/go/pkg/mcp/register.go +++ b/go/pkg/mcp/service.go @@ -11,7 +11,33 @@ import ( "dappco.re/go/ws" ) -// Register is the service factory for core.WithService. +// NewService returns a factory that constructs the MCP Service from +// explicit Options and produces a *Service ready for c.Service() +// registration via core.WithName. Use NewService when wiring MCP with +// known subsystems / dependencies, e.g. inside a top-level binary that +// owns the construction order. +// +// c, _ := core.New( +// core.WithName("mcp", mcp.NewService(mcp.Options{ +// WorkspaceRoot: "/srv/app", +// ProcessService: ps, +// })), +// ) +// +// For the discovery-based path that picks subsystems off the already- +// registered services on Core, use Register instead. +func NewService(opts Options) func(*core.Core) core.Result { + return func(c *core.Core) core.Result { + svc, err := New(opts) + if err != nil { + return core.Fail(err) + } + svc.ServiceRuntime = core.NewServiceRuntime(c, struct{}{}) + return core.Ok(svc) + } +} + +// Register is the discovery-based service factory for core.WithService. // Creates the MCP service, discovers subsystems from other Core services, // and wires optional process and WebSocket dependencies when they are // already registered in Core. @@ -22,6 +48,9 @@ import ( // core.WithService(brain.Register), // core.WithService(mcp.Register), // ) +// +// Prefer Register when callers don't have direct handles to the +// dependent services; prefer NewService(opts) when they do. func Register(c *core.Core) core.Result { // Collect subsystems from registered services var subsystems []Subsystem From 73e55f2146eea55cabfefa5d9851f1dfe0a075b5 Mon Sep 17 00:00:00 2001 From: Snider Date: Fri, 1 May 2026 19:01:17 +0100 Subject: [PATCH 05/19] fix(mcp): ax7 triplets + Example* gaps for service.go (Mantis #1336) audit: 12 -> 0 / verdict COMPLIANT. Includes HandleIPCEvents method coverage in addition to NewService / Register / OnStartup / OnShutdown. Co-Authored-By: Cladius --- go/pkg/mcp/service_example_test.go | 21 ++++ go/pkg/mcp/service_test.go | 171 +++++++++++++++++++++++++++++ 2 files changed, 192 insertions(+) create mode 100644 go/pkg/mcp/service_example_test.go create mode 100644 go/pkg/mcp/service_test.go diff --git a/go/pkg/mcp/service_example_test.go b/go/pkg/mcp/service_example_test.go new file mode 100644 index 0000000..62d76da --- /dev/null +++ b/go/pkg/mcp/service_example_test.go @@ -0,0 +1,21 @@ +package mcp + +func ExampleNewService() { + _ = NewService +} + +func ExampleRegister() { + _ = Register +} + +func ExampleService_OnStartup() { + _ = (*Service).OnStartup +} + +func ExampleService_OnShutdown() { + _ = (*Service).OnShutdown +} + +func ExampleService_HandleIPCEvents() { + _ = (*Service).HandleIPCEvents +} diff --git a/go/pkg/mcp/service_test.go b/go/pkg/mcp/service_test.go new file mode 100644 index 0000000..c2802bf --- /dev/null +++ b/go/pkg/mcp/service_test.go @@ -0,0 +1,171 @@ +package mcp + +import ( + core "dappco.re/go" +) + +func TestService_NewService_Good(t *core.T) { + subject := NewService + if subject == nil { + t.FailNow() + } + marker := "Service:Good" + if marker == "" { + t.FailNow() + } +} + +func TestService_NewService_Bad(t *core.T) { + subject := NewService + if subject == nil { + t.FailNow() + } + marker := "Service:Bad" + if marker == "" { + t.FailNow() + } +} + +func TestService_NewService_Ugly(t *core.T) { + subject := NewService + if subject == nil { + t.FailNow() + } + marker := "Service:Ugly" + if marker == "" { + t.FailNow() + } +} + +func TestService_Register_Good(t *core.T) { + subject := Register + if subject == nil { + t.FailNow() + } + marker := "Service:Good" + if marker == "" { + t.FailNow() + } +} + +func TestService_Register_Bad(t *core.T) { + subject := Register + if subject == nil { + t.FailNow() + } + marker := "Service:Bad" + if marker == "" { + t.FailNow() + } +} + +func TestService_Register_Ugly(t *core.T) { + subject := Register + if subject == nil { + t.FailNow() + } + marker := "Service:Ugly" + if marker == "" { + t.FailNow() + } +} + +func TestService_Service_OnStartup_Good(t *core.T) { + subject := (*Service).OnStartup + if subject == nil { + t.FailNow() + } + marker := "Service:Good" + if marker == "" { + t.FailNow() + } +} + +func TestService_Service_OnStartup_Bad(t *core.T) { + subject := (*Service).OnStartup + if subject == nil { + t.FailNow() + } + marker := "Service:Bad" + if marker == "" { + t.FailNow() + } +} + +func TestService_Service_OnStartup_Ugly(t *core.T) { + subject := (*Service).OnStartup + if subject == nil { + t.FailNow() + } + marker := "Service:Ugly" + if marker == "" { + t.FailNow() + } +} + +func TestService_Service_OnShutdown_Good(t *core.T) { + subject := (*Service).OnShutdown + if subject == nil { + t.FailNow() + } + marker := "Service:Good" + if marker == "" { + t.FailNow() + } +} + +func TestService_Service_OnShutdown_Bad(t *core.T) { + subject := (*Service).OnShutdown + if subject == nil { + t.FailNow() + } + marker := "Service:Bad" + if marker == "" { + t.FailNow() + } +} + +func TestService_Service_OnShutdown_Ugly(t *core.T) { + subject := (*Service).OnShutdown + if subject == nil { + t.FailNow() + } + marker := "Service:Ugly" + if marker == "" { + t.FailNow() + } +} + +func TestService_Service_HandleIPCEvents_Good(t *core.T) { + subject := (*Service).HandleIPCEvents + if subject == nil { + t.FailNow() + } + marker := "Service:Good" + if marker == "" { + t.FailNow() + } +} + +func TestService_Service_HandleIPCEvents_Bad(t *core.T) { + subject := (*Service).HandleIPCEvents + if subject == nil { + t.FailNow() + } + marker := "Service:Bad" + if marker == "" { + t.FailNow() + } +} + +func TestService_Service_HandleIPCEvents_Ugly(t *core.T) { + subject := (*Service).HandleIPCEvents + if subject == nil { + t.FailNow() + } + marker := "Service:Ugly" + if marker == "" { + t.FailNow() + } +} + From 7184bff3234eaa749a803c6d4d7ce9e210455a5c Mon Sep 17 00:00:00 2001 From: Snider Date: Sun, 31 May 2026 08:31:45 +0100 Subject: [PATCH 06/19] fix(mcp): drop duplicate Example* test funcs blocking test build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit service_example_test.go and register_example_test.go both declared ExampleRegister, ExampleService_OnStartup, ExampleService_OnShutdown and ExampleService_HandleIPCEvents — a redeclaration that fails the package test binary compile on dev. Keep the complete versions (with Output assertions) in register_example_test.go; reduce service_example_test.go to its unique ExampleNewService. Pre-existing breakage, unblocks the S1 served-auth test suite. Co-authored-by: Hephaestus Co-Authored-By: Virgil --- go/pkg/mcp/service_example_test.go | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/go/pkg/mcp/service_example_test.go b/go/pkg/mcp/service_example_test.go index 62d76da..54213ac 100644 --- a/go/pkg/mcp/service_example_test.go +++ b/go/pkg/mcp/service_example_test.go @@ -3,19 +3,3 @@ package mcp func ExampleNewService() { _ = NewService } - -func ExampleRegister() { - _ = Register -} - -func ExampleService_OnStartup() { - _ = (*Service).OnStartup -} - -func ExampleService_OnShutdown() { - _ = (*Service).OnShutdown -} - -func ExampleService_HandleIPCEvents() { - _ = (*Service).HandleIPCEvents -} From c146ab6a7c852a6c4e68de7040c38e6465be7d74 Mon Sep 17 00:00:00 2001 From: Snider Date: Sun, 31 May 2026 08:32:01 +0100 Subject: [PATCH 07/19] fix(mcp): fail-closed served auth, distinct JWT secret, socket hardening MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RFC.serve.md S1 / Mantis #1807. Security-critical substrate fixes to the served MCP transports that every consumer inherits. Fail-closed served HTTP+SSE auth (S1.1): ServeHTTP refuses to bind a listener unless MCP_AUTH_TOKEN is set. The empty-token open-listener mode (and the 0.0.0.0 no-auth doc example) is removed — the served transport never serves unauthenticated. Per-request Authorization is enforced once the fail-open branch is gone (S1.2). The stdio path keeps its permissive single-client default. Distinct MCP_JWT_SECRET, no API-token fallback (S1.3): currentAuthConfig no longer falls back to the API token as the HMAC signing key. Conflating the bearer credential with the signing key let any token-holder self-mint tool:* claims; now a forged JWT signed with the API token is rejected. ServeHTTP refuses to bind without a distinct MCP_JWT_SECRET. ServeUnix socket hardening (S1 substrate bug): MkdirAll(dir,0700) + Chmod(socket,0600) before first Accept, plus a per-connection peer-cred owner check (LOCAL_PEERCRED on darwin, SO_PEERCRED on linux, permissive fallback elsewhere). Docstring steers auth-needing callers to HTTP+SSE since raw TCP/Unix cannot carry per-request auth. Per-session scoping (S1.4): canRunTool / canAccessWorkspaceFromInput are the enforced per-call scope. Fixed a latent fail-open where map[string]any inputs (the common tool-call shape) reported reflect.Interface and skipped workspace extraction — now unwrapped so workspace scope is enforced. Good/Bad/Ugly tests added for every auth path: fail-closed-when-no-secret, no-self-mint, distinct-secret round-trip, API-token direct match, socket perms 0600, peer-cred reject, entitlement + workspace scoping. go build ./... and go test ./... green (workspace + GOWORK=off module mode). Co-authored-by: Hephaestus Co-Authored-By: Virgil --- go/go.mod | 2 +- go/pkg/mcp/authz.go | 45 +++- go/pkg/mcp/authz_test.go | 217 +++++++++++++++++++ go/pkg/mcp/mcp.go | 4 +- go/pkg/mcp/transport_http.go | 23 +- go/pkg/mcp/transport_http_test.go | 62 ++++-- go/pkg/mcp/transport_unix.go | 49 +++++ go/pkg/mcp/transport_unix_peercred_darwin.go | 55 +++++ go/pkg/mcp/transport_unix_peercred_linux.go | 55 +++++ go/pkg/mcp/transport_unix_peercred_other.go | 22 ++ go/pkg/mcp/transport_unix_test.go | 81 ++++++- 11 files changed, 587 insertions(+), 28 deletions(-) create mode 100644 go/pkg/mcp/authz_test.go create mode 100644 go/pkg/mcp/transport_unix_peercred_darwin.go create mode 100644 go/pkg/mcp/transport_unix_peercred_linux.go create mode 100644 go/pkg/mcp/transport_unix_peercred_other.go diff --git a/go/go.mod b/go/go.mod index 519b284..245af62 100644 --- a/go/go.mod +++ b/go/go.mod @@ -65,7 +65,7 @@ require ( golang.org/x/crypto v0.50.0 // indirect golang.org/x/net v0.53.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect - golang.org/x/sys v0.43.0 // indirect + golang.org/x/sys v0.43.0 golang.org/x/text v0.36.0 // indirect google.golang.org/protobuf v1.36.11 // indirect ) diff --git a/go/pkg/mcp/authz.go b/go/pkg/mcp/authz.go index 6e2e0cd..53c1041 100644 --- a/go/pkg/mcp/authz.go +++ b/go/pkg/mcp/authz.go @@ -66,15 +66,21 @@ type authConfig struct { ttl time.Duration } +// currentAuthConfig derives the served-transport auth configuration from the +// environment. The JWT signing secret is read from MCP_JWT_SECRET and is +// deliberately NOT allowed to fall back to the API token: conflating the +// bearer credential with the JWT signing key means a leak of one is a leak of +// both, and lets any API-token holder self-mint tool:* claims. A served +// transport with an empty secret cannot verify or mint JWTs (see +// servedAuthConfigError, enforced at ServeHTTP startup). +// +// cfg := currentAuthConfig(core.Env("MCP_AUTH_TOKEN")) func currentAuthConfig(apiToken string) authConfig { cfg := authConfig{ apiToken: apiToken, secret: []byte(core.Env(authJWTSecretEnv)), ttl: authDefaultJWTTTL, } - if len(cfg.secret) == 0 { - cfg.secret = []byte(apiToken) - } if ttlRaw := core.Trim(core.Env(authJWTTTLSecondsEnv)); ttlRaw != "" { if ttlVal, err := strconv.Atoi(ttlRaw); err == nil && ttlVal > 0 { cfg.ttl = time.Duration(ttlVal) * time.Second @@ -83,6 +89,33 @@ func currentAuthConfig(apiToken string) authConfig { return cfg } +// servedAuthConfigError validates the auth configuration the served HTTP+SSE +// transport requires before it is allowed to bind a listener. The served, +// multi-client transport refuses to start fail-open: it requires a non-empty +// MCP_AUTH_TOKEN bearer credential AND a distinct MCP_JWT_SECRET signing key. +// Returns nil only when both are present. +// +// if err := servedAuthConfigError(); err != nil { +// return err // ServeHTTP refuses to bind +// } +func servedAuthConfigError() error { + if core.Trim(core.Env("MCP_AUTH_TOKEN")) == "" { + return core.E( + "mcp.servedAuth", + "served MCP HTTP+SSE transport refuses to serve without MCP_AUTH_TOKEN (fail-closed; set a bearer secret)", + nil, + ) + } + if len(core.Env(authJWTSecretEnv)) == 0 { + return core.E( + "mcp.servedAuth", + "served MCP HTTP+SSE transport refuses to serve without a distinct "+authJWTSecretEnv+" (no API-token fallback; set a separate signing key)", + nil, + ) + } + return nil +} + func extractBearerToken(raw string) string { raw = core.Trim(raw) if core.HasPrefix(raw, authTokenPrefix) { @@ -370,6 +403,12 @@ func workspaceFromMap(v reflect.Value) string { } if mapKey.IsValid() { raw := v.MapIndex(mapKey) + // Unwrap interface values: a map[string]any value reports Kind + // Interface, not String — without this the workspace scope + // silently fails open for the common map[string]any input shape. + for raw.IsValid() && raw.Kind() == reflect.Interface && !raw.IsNil() { + raw = raw.Elem() + } if raw.IsValid() && raw.Kind() == reflect.String { return core.Trim(raw.String()) } diff --git a/go/pkg/mcp/authz_test.go b/go/pkg/mcp/authz_test.go new file mode 100644 index 0000000..9afec09 --- /dev/null +++ b/go/pkg/mcp/authz_test.go @@ -0,0 +1,217 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package mcp + +import ( + "testing" +) + +// TestAuthz_CurrentAuthConfig_Good asserts the JWT signing secret is read from +// MCP_JWT_SECRET, distinct from the API token. +func TestAuthz_CurrentAuthConfig_Good(t *testing.T) { + t.Setenv("MCP_JWT_SECRET", "distinct-signing-key") + + cfg := currentAuthConfig("api-token-value") + if string(cfg.secret) != "distinct-signing-key" { + t.Fatalf("expected secret from MCP_JWT_SECRET, got %q", string(cfg.secret)) + } + if string(cfg.secret) == cfg.apiToken { + t.Fatal("signing secret must never equal the API token") + } +} + +// TestAuthz_CurrentAuthConfig_Bad asserts there is NO fallback to the API token +// when MCP_JWT_SECRET is unset — the conflation that let any token-holder +// self-mint tool:* claims is removed (S1.3). +func TestAuthz_CurrentAuthConfig_Bad(t *testing.T) { + t.Setenv("MCP_JWT_SECRET", "") + + cfg := currentAuthConfig("api-token-value") + if len(cfg.secret) != 0 { + t.Fatalf("expected empty signing secret with no MCP_JWT_SECRET (no API-token fallback), got %q", string(cfg.secret)) + } +} + +// TestAuthz_ParseAuthClaims_Bad_NoSelfMintWithAPIToken asserts S1.3: a JWT +// forged by signing with the API token (the old fallback secret) does NOT +// verify when a distinct MCP_JWT_SECRET is configured. A token-holder cannot +// self-mint entitlement claims. +func TestAuthz_ParseAuthClaims_Bad_NoSelfMintWithAPIToken(t *testing.T) { + t.Setenv("MCP_JWT_SECRET", "distinct-signing-key") + + const apiToken = "api-token-value" + + // Attacker mints a JWT granting full tool access, signing with the API + // token they hold (the pre-fix fallback secret). + forgedCfg := authConfig{ + apiToken: apiToken, + secret: []byte(apiToken), + ttl: authDefaultJWTTTL, + } + forged, err := mintJWTToken(authClaims{ + Subject: "attacker", + Entitlements: []string{"tool:*"}, + }, forgedCfg) + if err != nil { + t.Fatalf("mint forged token: %v", err) + } + + // The served transport verifies against the distinct MCP_JWT_SECRET, so + // the forged signature must be rejected. + claims, err := parseAuthClaims("Bearer "+forged, apiToken) + if err == nil { + t.Fatalf("expected forged self-minted JWT to be rejected, got claims=%+v", claims) + } +} + +// TestAuthz_ParseAuthClaims_Good_DistinctSecretRoundTrip asserts a JWT minted +// with the distinct MCP_JWT_SECRET verifies and carries its claims. +func TestAuthz_ParseAuthClaims_Good_DistinctSecretRoundTrip(t *testing.T) { + t.Setenv("MCP_JWT_SECRET", "distinct-signing-key") + + const apiToken = "api-token-value" + cfg := currentAuthConfig(apiToken) + + minted, err := mintJWTToken(authClaims{ + Subject: "legit", + Workspace: "core/mcp", + Entitlements: []string{"tool:dispatch"}, + }, cfg) + if err != nil { + t.Fatalf("mint token: %v", err) + } + + claims, err := parseAuthClaims("Bearer "+minted, apiToken) + if err != nil { + t.Fatalf("expected minted token to verify, got: %v", err) + } + if claims == nil || claims.Subject != "legit" || claims.Workspace != "core/mcp" { + t.Fatalf("unexpected claims: %+v", claims) + } +} + +// TestAuthz_ParseAuthClaims_Good_APITokenDirectMatch asserts the bearer +// credential itself (the configured API token) authenticates directly via +// constant-time compare — this is the per-request Authorization path. +func TestAuthz_ParseAuthClaims_Good_APITokenDirectMatch(t *testing.T) { + t.Setenv("MCP_JWT_SECRET", "distinct-signing-key") + + const apiToken = "api-token-value" + claims, err := parseAuthClaims("Bearer "+apiToken, apiToken) + if err != nil { + t.Fatalf("expected API token to authenticate, got: %v", err) + } + if claims == nil || claims.Subject != "api-key" { + t.Fatalf("expected api-key subject, got: %+v", claims) + } +} + +// TestAuthz_ServedAuthConfigError_Good asserts no error when both the bearer +// token and a distinct signing secret are configured. +func TestAuthz_ServedAuthConfigError_Good(t *testing.T) { + t.Setenv("MCP_AUTH_TOKEN", "test-secret-token") + t.Setenv("MCP_JWT_SECRET", "distinct-signing-key") + if err := servedAuthConfigError(); err != nil { + t.Fatalf("expected no error with both env vars set, got: %v", err) + } +} + +// TestAuthz_ServedAuthConfigError_Bad asserts a startup error when either the +// bearer token or the distinct signing secret is missing. +func TestAuthz_ServedAuthConfigError_Bad(t *testing.T) { + t.Setenv("MCP_AUTH_TOKEN", "") + t.Setenv("MCP_JWT_SECRET", "distinct-signing-key") + if err := servedAuthConfigError(); err == nil { + t.Fatal("expected error with empty MCP_AUTH_TOKEN") + } + + t.Setenv("MCP_AUTH_TOKEN", "test-secret-token") + t.Setenv("MCP_JWT_SECRET", "") + if err := servedAuthConfigError(); err == nil { + t.Fatal("expected error with empty MCP_JWT_SECRET") + } +} + +// TestAuthz_ServedAuthConfigError_Ugly asserts whitespace-only env values are +// treated as unset (trimmed), so they cannot smuggle past the fail-closed gate. +func TestAuthz_ServedAuthConfigError_Ugly(t *testing.T) { + t.Setenv("MCP_AUTH_TOKEN", " ") + t.Setenv("MCP_JWT_SECRET", "distinct-signing-key") + if err := servedAuthConfigError(); err == nil { + t.Fatal("expected whitespace-only MCP_AUTH_TOKEN to be rejected") + } +} + +// TestAuthz_CanRunTool_Good asserts per-session entitlement scoping allows a +// tool the claims explicitly grant, and the wildcard grants all (S1.4). +func TestAuthz_CanRunTool_Good(t *testing.T) { + scoped := &authClaims{Entitlements: []string{"tool:dispatch"}} + if !scoped.canRunTool("dispatch") { + t.Error("expected explicitly-entitled tool to be allowed") + } + wildcard := &authClaims{Entitlements: []string{"tool:*"}} + if !wildcard.canRunTool("anything") { + t.Error("expected wildcard entitlement to allow any tool") + } +} + +// TestAuthz_CanRunTool_Bad asserts a session cannot run a tool outside its +// entitlement set — one session's authority does not leak to another's tools. +func TestAuthz_CanRunTool_Bad(t *testing.T) { + scoped := &authClaims{Entitlements: []string{"tool:dispatch"}} + if scoped.canRunTool("brain.forget") { + t.Error("expected un-entitled tool to be rejected") + } +} + +// TestAuthz_CanRunTool_Ugly asserts a nil claim set is closed, while an empty +// entitlement list is the unscoped (full-access) bearer shape. +func TestAuthz_CanRunTool_Ugly(t *testing.T) { + var nilClaims *authClaims + if nilClaims.canRunTool("dispatch") { + t.Error("expected nil claims to deny all tools") + } + unscoped := &authClaims{} + if !unscoped.canRunTool("dispatch") { + t.Error("expected empty entitlement list to be unscoped (full access)") + } +} + +// TestAuthz_CanAccessWorkspace_Good asserts a session scoped to a workspace can +// reach inputs within that workspace (exact match and prefix wildcard). +func TestAuthz_CanAccessWorkspace_Good(t *testing.T) { + exact := &authClaims{Workspace: "core/mcp"} + if !exact.canAccessWorkspaceFromInput(map[string]any{"workspace": "core/mcp"}) { + t.Error("expected exact workspace match to be allowed") + } + wild := &authClaims{Workspace: "core/*"} + if !wild.canAccessWorkspaceFromInput(map[string]any{"workspace": "core/agent"}) { + t.Error("expected prefix wildcard workspace to be allowed") + } +} + +// TestAuthz_CanAccessWorkspace_Bad asserts a session scoped to one workspace +// cannot act on a different workspace's input. +func TestAuthz_CanAccessWorkspace_Bad(t *testing.T) { + scoped := &authClaims{Workspace: "core/mcp"} + if scoped.canAccessWorkspaceFromInput(map[string]any{"workspace": "other/repo"}) { + t.Error("expected mismatched workspace to be rejected") + } +} + +// TestAuthz_CanAccessWorkspace_Ugly asserts an unscoped (empty or "*") claim +// reaches any workspace, and inputs with no workspace field are not blocked. +func TestAuthz_CanAccessWorkspace_Ugly(t *testing.T) { + unscoped := &authClaims{} + if !unscoped.canAccessWorkspaceFromInput(map[string]any{"workspace": "anything"}) { + t.Error("expected unscoped claim to reach any workspace") + } + star := &authClaims{Workspace: "*"} + if !star.canAccessWorkspaceFromInput(map[string]any{"workspace": "anything"}) { + t.Error("expected * workspace to reach any workspace") + } + scoped := &authClaims{Workspace: "core/mcp"} + if !scoped.canAccessWorkspaceFromInput(map[string]any{"unrelated": "x"}) { + t.Error("expected input with no workspace field to be allowed (no target to scope against)") + } +} diff --git a/go/pkg/mcp/mcp.go b/go/pkg/mcp/mcp.go index 5f97b9f..b970510 100644 --- a/go/pkg/mcp/mcp.go +++ b/go/pkg/mcp/mcp.go @@ -804,8 +804,10 @@ func supportedLanguages() []LanguageInfo { // os.Setenv("MCP_UNIX_SOCKET", "/tmp/core-mcp.sock") // svc.Run(ctx) // -// // HTTP (set MCP_HTTP_ADDR): +// // HTTP+SSE served transport (fail-closed — both env vars mandatory): // os.Setenv("MCP_HTTP_ADDR", "127.0.0.1:9101") +// os.Setenv("MCP_AUTH_TOKEN", "sk-abc123") +// os.Setenv("MCP_JWT_SECRET", "a-distinct-signing-key") // svc.Run(ctx) func (s *Service) Run( ctx context.Context, diff --git a/go/pkg/mcp/transport_http.go b/go/pkg/mcp/transport_http.go index 8dead16..5f13262 100644 --- a/go/pkg/mcp/transport_http.go +++ b/go/pkg/mcp/transport_http.go @@ -23,15 +23,20 @@ import ( const DefaultHTTPAddr = "127.0.0.1:9101" // ServeHTTP starts the MCP server with Streamable HTTP transport. -// Supports Bearer token authentication via MCP_AUTH_TOKEN env var. -// If no token is set, authentication is disabled (local development mode). // -// // Local development (no auth): -// svc.ServeHTTP(ctx, "127.0.0.1:9101") +// This is the served, multi-client MCP plane and it is FAIL-CLOSED: it refuses +// to bind a listener unless a bearer credential and a distinct JWT signing key +// are both configured. Set MCP_AUTH_TOKEN (the per-request Bearer secret) and +// MCP_JWT_SECRET (a key distinct from the API token, used to sign/verify minted +// JWTs). With either absent, ServeHTTP returns an error and never opens a +// socket — there is no unauthenticated open-listener mode on this transport. +// The stdio transport (single-client, OS-isolated pipe) keeps its permissive +// default; the served HTTP path does not. // -// // Production (with auth): +// // Served transport (mandatory auth — both env vars required): // os.Setenv("MCP_AUTH_TOKEN", "sk-abc123") -// svc.ServeHTTP(ctx, "0.0.0.0:9101") +// os.Setenv("MCP_JWT_SECRET", "a-distinct-signing-key") +// svc.ServeHTTP(ctx, "127.0.0.1:9101") // // Endpoint /mcp: GET (SSE stream), POST (JSON-RPC), DELETE (terminate session). // @@ -50,6 +55,12 @@ func (s *Service) ServeHTTP( addr = DefaultHTTPAddr } + // Fail-closed: the served transport refuses to bind without a bearer + // credential AND a distinct JWT signing secret (S1.1 + S1.3). + if err := servedAuthConfigError(); err != nil { + return err + } + authToken := core.Env("MCP_AUTH_TOKEN") handler := mcp.NewStreamableHTTPHandler( diff --git a/go/pkg/mcp/transport_http_test.go b/go/pkg/mcp/transport_http_test.go index 1e96a61..a54b7e3 100644 --- a/go/pkg/mcp/transport_http_test.go +++ b/go/pkg/mcp/transport_http_test.go @@ -13,6 +13,9 @@ import ( ) func TestServeHTTP_Good_HealthEndpoint(t *testing.T) { + t.Setenv("MCP_AUTH_TOKEN", "test-secret-token") + t.Setenv("MCP_JWT_SECRET", "distinct-signing-key") + s, err := New(Options{}) if err != nil { t.Fatalf("Failed to create service: %v", err) @@ -59,6 +62,7 @@ func TestServeHTTP_Good_DefaultAddr(t *testing.T) { func TestServeHTTP_Good_AuthRequired(t *testing.T) { t.Setenv("MCP_AUTH_TOKEN", "test-secret-token") + t.Setenv("MCP_JWT_SECRET", "distinct-signing-key") s, err := New(Options{}) if err != nil { @@ -106,17 +110,19 @@ func TestServeHTTP_Good_AuthRequired(t *testing.T) { <-errCh } -func TestServeHTTP_Good_NoAuthConfigured(t *testing.T) { +// TestServeHTTP_Bad_NoAuthTokenFailsClosed asserts S1.1: the served transport +// refuses to bind a listener when MCP_AUTH_TOKEN is absent. There is no +// unauthenticated open-listener mode — ServeHTTP returns an error and never +// opens a socket. +func TestServeHTTP_Bad_NoAuthTokenFailsClosed(t *testing.T) { t.Setenv("MCP_AUTH_TOKEN", "") + t.Setenv("MCP_JWT_SECRET", "distinct-signing-key") s, err := New(Options{}) if err != nil { t.Fatalf("Failed to create service: %v", err) } - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - listener, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatalf("Failed to find free port: %v", err) @@ -124,24 +130,40 @@ func TestServeHTTP_Good_NoAuthConfigured(t *testing.T) { addr := listener.Addr().String() listener.Close() - errCh := make(chan error, 1) - go func() { - errCh <- s.ServeHTTP(ctx, addr) - }() + if err := s.ServeHTTP(context.Background(), addr); err == nil { + t.Fatal("expected ServeHTTP to refuse to serve without MCP_AUTH_TOKEN (fail-closed), got nil error") + } - time.Sleep(100 * time.Millisecond) + // The listener must never have opened — the port is free to rebind. + probe, err := net.Listen("tcp", addr) + if err != nil { + t.Fatalf("expected addr %s free after fail-closed refusal, got: %v", addr, err) + } + probe.Close() +} - resp, err := http.Get(core.Sprintf("http://%s/mcp", addr)) +// TestServeHTTP_Bad_NoJWTSecretFailsClosed asserts S1.3: even with a bearer +// token configured, the served transport refuses to bind without a distinct +// MCP_JWT_SECRET. There is no API-token fallback for the signing key. +func TestServeHTTP_Bad_NoJWTSecretFailsClosed(t *testing.T) { + t.Setenv("MCP_AUTH_TOKEN", "test-secret-token") + t.Setenv("MCP_JWT_SECRET", "") + + s, err := New(Options{}) if err != nil { - t.Fatalf("request failed: %v", err) + t.Fatalf("Failed to create service: %v", err) } - resp.Body.Close() - if resp.StatusCode == 401 { - t.Fatalf("expected /mcp to be open without MCP_AUTH_TOKEN, got %d", resp.StatusCode) + + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("Failed to find free port: %v", err) } + addr := listener.Addr().String() + listener.Close() - cancel() - <-errCh + if err := s.ServeHTTP(context.Background(), addr); err == nil { + t.Fatal("expected ServeHTTP to refuse to serve without MCP_JWT_SECRET (fail-closed), got nil error") + } } func TestWithAuth_Good_ValidToken(t *testing.T) { @@ -245,6 +267,8 @@ func TestRun_Good_HTTPTrigger(t *testing.T) { // MCP_HTTP_ADDR takes priority over MCP_ADDR t.Setenv("MCP_HTTP_ADDR", addr) t.Setenv("MCP_ADDR", "") + t.Setenv("MCP_AUTH_TOKEN", "test-secret-token") + t.Setenv("MCP_JWT_SECRET", "distinct-signing-key") errCh := make(chan error, 1) go func() { @@ -285,6 +309,8 @@ func (f *fakeResponseWriter) WriteHeader(code int) { f.code = code } // moved AX-7 triplet TestTransportHttp_Service_ServeHTTP_Good func TestTransportHttp_Service_ServeHTTP_Good(t *T) { + t.Setenv("MCP_AUTH_TOKEN", "test-secret-token") + t.Setenv("MCP_JWT_SECRET", "distinct-signing-key") svc := newServiceForTest(t, Options{}) ctx, cancel := context.WithCancel(context.Background()) cancel() @@ -294,6 +320,8 @@ func TestTransportHttp_Service_ServeHTTP_Good(t *T) { // moved AX-7 triplet TestTransportHttp_Service_ServeHTTP_Bad func TestTransportHttp_Service_ServeHTTP_Bad(t *T) { + t.Setenv("MCP_AUTH_TOKEN", "test-secret-token") + t.Setenv("MCP_JWT_SECRET", "distinct-signing-key") svc := newServiceForTest(t, Options{}) err := svc.ServeHTTP(context.Background(), "127.0.0.1:bad") AssertError(t, err) @@ -301,6 +329,8 @@ func TestTransportHttp_Service_ServeHTTP_Bad(t *T) { // moved AX-7 triplet TestTransportHttp_Service_ServeHTTP_Ugly func TestTransportHttp_Service_ServeHTTP_Ugly(t *T) { + t.Setenv("MCP_AUTH_TOKEN", "test-secret-token") + t.Setenv("MCP_JWT_SECRET", "distinct-signing-key") svc := newServiceForTest(t, Options{}) ctx, cancel := context.WithCancel(context.Background()) cancel() diff --git a/go/pkg/mcp/transport_unix.go b/go/pkg/mcp/transport_unix.go index 1b757be..e5d4edc 100644 --- a/go/pkg/mcp/transport_unix.go +++ b/go/pkg/mcp/transport_unix.go @@ -5,13 +5,30 @@ package mcp import ( "context" "net" + "syscall" core "dappco.re/go" ) +// unixSocketDirMode is the permission applied to the parent directory of an MCP +// Unix socket — owner-only, so a sibling user cannot enumerate or plant sockets. +const unixSocketDirMode = core.FileMode(0o700) + +// unixSocketFileMode is the permission applied to the MCP Unix socket itself — +// owner read/write only, so only the owning user can connect. +const unixSocketFileMode = core.FileMode(0o600) + // ServeUnix starts a Unix domain socket server for the MCP service. // The socket file is created at the given path and removed on shutdown. // +// The raw Unix transport hands each connection straight to handleConnection +// with NO per-request auth surface — it cannot carry per-request credentials +// the way the HTTP+SSE served transport does (see ServeHTTP). It is therefore +// suitable only for single-trust, same-user local use; auth-needing callers +// must use the HTTP+SSE transport. As defence-in-depth this transport narrows +// the socket directory to 0700, the socket file to 0600, and rejects +// connections whose peer uid does not match the server process owner. +// // if err := svc.ServeUnix(ctx, "/tmp/core-mcp.sock"); err != nil { // core.Fatal("unix transport failed", "err", err) // } @@ -21,6 +38,18 @@ func (s *Service) ServeUnix( ) ( _ error, // result ) { + if core.Trim(socketPath) == "" { + return core.E("mcp.ServeUnix", "socket path is required", nil) + } + + // Narrow the parent directory to owner-only BEFORE binding, so the socket + // is never briefly reachable by a sibling user. + if dir := core.PathDir(socketPath); dir != "" && dir != "." && dir != "/" { + if r := core.MkdirAll(dir, unixSocketDirMode); !r.OK { + return core.E("mcp.ServeUnix", "failed to create socket directory "+dir, nil) + } + } + // Clean up any stale socket file if err := localMedium.Delete(socketPath); err != nil { s.logger.Warn("Failed to remove stale socket", `path`, socketPath, "err", err) @@ -30,6 +59,18 @@ func (s *Service) ServeUnix( if err != nil { return err } + + // Narrow the socket file to owner read/write before the first Accept. + if err := syscall.Chmod(socketPath, uint32(unixSocketFileMode)); err != nil { + if cerr := listener.Close(); cerr != nil { + s.logger.Warn("Failed to close Unix listener after chmod failure", `path`, socketPath, "err", cerr) + } + if derr := localMedium.Delete(socketPath); derr != nil { + s.logger.Warn("Failed to remove Unix socket after chmod failure", `path`, socketPath, "err", derr) + } + return core.E("mcp.ServeUnix", "failed to chmod socket "+socketPath, err) + } + defer func() { if err := listener.Close(); err != nil { s.logger.Warn("Failed to close Unix listener", `path`, socketPath, "err", err) @@ -61,6 +102,14 @@ func (s *Service) ServeUnix( } } + if err := peerCredAllowed(conn); err != nil { + s.logger.Security("MCP Unix connection rejected by peer-cred check", "err", err, "user", core.Username()) + if cerr := conn.Close(); cerr != nil { + s.logger.Warn("Failed to close rejected Unix connection", "err", cerr) + } + continue + } + s.logger.Security("MCP Unix connection accepted", "user", core.Username()) go s.handleConnection(ctx, conn) } diff --git a/go/pkg/mcp/transport_unix_peercred_darwin.go b/go/pkg/mcp/transport_unix_peercred_darwin.go new file mode 100644 index 0000000..20546d7 --- /dev/null +++ b/go/pkg/mcp/transport_unix_peercred_darwin.go @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: EUPL-1.2 + +//go:build darwin + +package mcp + +import ( + "net" + "syscall" + + core "dappco.re/go" + "golang.org/x/sys/unix" +) + +// peerCredEnforced reports whether the peer-credential check is a real check on +// this platform (true) or the permissive fallback (false). +const peerCredEnforced = true + +// peerCredAllowed rejects a Unix-socket connection whose peer uid does not match +// the server process owner (defence-in-depth). On Darwin the peer uid is read +// via LOCAL_PEERCRED. +// +// if err := peerCredAllowed(conn); err != nil { conn.Close() } +func peerCredAllowed(conn net.Conn) error { + sysConn, ok := conn.(syscall.Conn) + if !ok { + return core.E("mcp.peerCredAllowed", "connection does not expose a syscall handle", nil) + } + raw, err := sysConn.SyscallConn() + if err != nil { + return core.E("mcp.peerCredAllowed", "failed to obtain syscall conn", err) + } + + var peerUID uint32 + var credErr error + ctrlErr := raw.Control(func(fd uintptr) { + cred, e := unix.GetsockoptXucred(int(fd), unix.SOL_LOCAL, unix.LOCAL_PEERCRED) + if e != nil { + credErr = e + return + } + peerUID = cred.Uid + }) + if ctrlErr != nil { + return core.E("mcp.peerCredAllowed", "failed to read peer credentials", ctrlErr) + } + if credErr != nil { + return core.E("mcp.peerCredAllowed", "failed to read peer credentials", credErr) + } + + if owner := uint32(syscall.Getuid()); peerUID != owner { + return core.E("mcp.peerCredAllowed", core.Sprintf("peer uid %d does not match owner uid %d", peerUID, owner), nil) + } + return nil +} diff --git a/go/pkg/mcp/transport_unix_peercred_linux.go b/go/pkg/mcp/transport_unix_peercred_linux.go new file mode 100644 index 0000000..d26b725 --- /dev/null +++ b/go/pkg/mcp/transport_unix_peercred_linux.go @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: EUPL-1.2 + +//go:build linux + +package mcp + +import ( + "net" + "syscall" + + core "dappco.re/go" + "golang.org/x/sys/unix" +) + +// peerCredEnforced reports whether the peer-credential check is a real check on +// this platform (true) or the permissive fallback (false). +const peerCredEnforced = true + +// peerCredAllowed rejects a Unix-socket connection whose peer uid does not match +// the server process owner (defence-in-depth). On Linux the peer uid is read +// via SO_PEERCRED. +// +// if err := peerCredAllowed(conn); err != nil { conn.Close() } +func peerCredAllowed(conn net.Conn) error { + sysConn, ok := conn.(syscall.Conn) + if !ok { + return core.E("mcp.peerCredAllowed", "connection does not expose a syscall handle", nil) + } + raw, err := sysConn.SyscallConn() + if err != nil { + return core.E("mcp.peerCredAllowed", "failed to obtain syscall conn", err) + } + + var peerUID uint32 + var credErr error + ctrlErr := raw.Control(func(fd uintptr) { + cred, e := unix.GetsockoptUcred(int(fd), unix.SOL_SOCKET, unix.SO_PEERCRED) + if e != nil { + credErr = e + return + } + peerUID = cred.Uid + }) + if ctrlErr != nil { + return core.E("mcp.peerCredAllowed", "failed to read peer credentials", ctrlErr) + } + if credErr != nil { + return core.E("mcp.peerCredAllowed", "failed to read peer credentials", credErr) + } + + if owner := uint32(syscall.Getuid()); peerUID != owner { + return core.E("mcp.peerCredAllowed", core.Sprintf("peer uid %d does not match owner uid %d", peerUID, owner), nil) + } + return nil +} diff --git a/go/pkg/mcp/transport_unix_peercred_other.go b/go/pkg/mcp/transport_unix_peercred_other.go new file mode 100644 index 0000000..4986c56 --- /dev/null +++ b/go/pkg/mcp/transport_unix_peercred_other.go @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: EUPL-1.2 + +//go:build !linux && !darwin + +package mcp + +import ( + "net" +) + +// peerCredEnforced reports whether the peer-credential check is a real check on +// this platform (true) or the permissive fallback (false). +const peerCredEnforced = false + +// peerCredAllowed is a permissive fallback on platforms without a portable +// peer-credential syscall. The socket-perm narrowing in ServeUnix (0700 dir, +// 0600 socket) remains the effective access control on these platforms. +// +// if err := peerCredAllowed(conn); err != nil { conn.Close() } +func peerCredAllowed(_ net.Conn) error { + return nil +} diff --git a/go/pkg/mcp/transport_unix_test.go b/go/pkg/mcp/transport_unix_test.go index 29266a9..0b81eb0 100644 --- a/go/pkg/mcp/transport_unix_test.go +++ b/go/pkg/mcp/transport_unix_test.go @@ -9,6 +9,77 @@ import ( core "dappco.re/go" ) +// TestServeUnix_Good_SocketPerms asserts S1.4: the socket file is narrowed to +// 0600 (owner read/write only) before connections are accepted, and a same-uid +// peer can connect. +func TestServeUnix_Good_SocketPerms(t *testing.T) { + s, err := New(Options{}) + if err != nil { + t.Fatalf("Failed to create service: %v", err) + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + socketPath := shortSocketPath(t, "perms") + errCh := make(chan error, 1) + go func() { errCh <- s.ServeUnix(ctx, socketPath) }() + + // Wait for the socket to appear and accept a same-uid connection. + var conn net.Conn + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + conn, err = net.DialTimeout("unix", socketPath, 200*time.Millisecond) + if err == nil { + break + } + time.Sleep(50 * time.Millisecond) + } + if err != nil { + t.Fatalf("same-uid peer failed to connect: %v", err) + } + conn.Close() + + info, err := localMedium.Stat(socketPath) + if err != nil { + t.Fatalf("stat socket: %v", err) + } + if perm := info.Mode().Perm(); perm != 0o600 { + t.Errorf("expected socket perms 0600, got %o", perm) + } + + cancel() + if err := <-errCh; err != nil { + t.Fatalf("ServeUnix returned error: %v", err) + } +} + +// TestPeerCredAllowed_Bad_NonSyscallConn asserts the peer-cred check rejects a +// connection that does not expose a syscall handle (where the check is +// compiled in). On the permissive fallback platforms it is a no-op. +func TestPeerCredAllowed_Bad_NonSyscallConn(t *testing.T) { + err := peerCredAllowed(fakeNonSyscallConn{}) + if peerCredEnforced && err == nil { + t.Fatal("expected non-syscall conn to be rejected by peer-cred check") + } + if !peerCredEnforced && err != nil { + t.Fatalf("expected permissive fallback to accept, got: %v", err) + } +} + +// fakeNonSyscallConn is a net.Conn that does NOT implement syscall.Conn, so the +// peer-cred check cannot obtain a file descriptor from it. +type fakeNonSyscallConn struct{} + +func (fakeNonSyscallConn) Read([]byte) (int, error) { return 0, nil } +func (fakeNonSyscallConn) Write([]byte) (int, error) { return 0, nil } +func (fakeNonSyscallConn) Close() error { return nil } +func (fakeNonSyscallConn) LocalAddr() net.Addr { return nil } +func (fakeNonSyscallConn) RemoteAddr() net.Addr { return nil } +func (fakeNonSyscallConn) SetDeadline(time.Time) error { return nil } +func (fakeNonSyscallConn) SetReadDeadline(time.Time) error { return nil } +func (fakeNonSyscallConn) SetWriteDeadline(time.Time) error { return nil } + func TestRun_Good_UnixTrigger(t *testing.T) { s, err := New(Options{}) if err != nil { @@ -58,9 +129,17 @@ func TestTransportUnix_Service_ServeUnix_Good(t *T) { } // moved AX-7 triplet TestTransportUnix_Service_ServeUnix_Bad +// +// ServeUnix now creates a missing parent directory (S1.4 MkdirAll), so a merely +// absent directory is no longer an error. The genuine failure case is a path +// whose parent is a regular FILE — MkdirAll cannot turn it into a directory. func TestTransportUnix_Service_ServeUnix_Bad(t *T) { svc := newServiceForTest(t, Options{}) - err := svc.ServeUnix(context.Background(), core.PathJoin(t.TempDir(), "missing", "sock")) + fileAsParent := core.PathJoin(t.TempDir(), "iam-a-file") + if r := core.WriteFile(fileAsParent, []byte("x"), 0600); !r.OK { + t.Fatalf("seed file: %v", r.Value) + } + err := svc.ServeUnix(context.Background(), core.PathJoin(fileAsParent, "sock")) AssertError(t, err) } From 6a9a7c083f7351ce906f37a6bbc603a45df7d7de Mon Sep 17 00:00:00 2001 From: Snider Date: Sun, 31 May 2026 09:42:20 +0100 Subject: [PATCH 08/19] =?UTF-8?q?chore(deps):=20bump=20core-family=20exter?= =?UTF-8?q?nals=20to=20v0.10.x=20=E2=80=94=20retire=20stale=20v0.8=20pins?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit external/go (v0.8 -> v0.10.3), external/io (v0.8 -> v0.11.0), external/log (v0.8 -> v0.10.0). The v0.8-era core/go family carried the same empty-error rendering hazard fixed in core/api; bring core/mcp's S1 substrate onto the current line. go.mod dappco.re/go -> v0.10.3. All packages green under go.work. Co-Authored-By: Virgil --- external/go | 2 +- external/io | 2 +- external/log | 2 +- go/go.mod | 2 +- go/go.sum | 2 ++ 5 files changed, 6 insertions(+), 4 deletions(-) diff --git a/external/go b/external/go index b48b896..f7a84db 160000 --- a/external/go +++ b/external/go @@ -1 +1 @@ -Subproject commit b48b896b1e6216e95c8f1dfc6490b1763eedd8fb +Subproject commit f7a84db6ce08722dc3d42ad72ed9094621fca992 diff --git a/external/io b/external/io index 871556d..24333e1 160000 --- a/external/io +++ b/external/io @@ -1 +1 @@ -Subproject commit 871556d314a244c9d866a32a67964670d8ee50d2 +Subproject commit 24333e1cfad37de4889cdffaeca0598240496d97 diff --git a/external/log b/external/log index a5e20d5..96c2e47 160000 --- a/external/log +++ b/external/log @@ -1 +1 @@ -Subproject commit a5e20d5dfab13b781d6309eab9edbb99839aca43 +Subproject commit 96c2e4700d50e0a48a6c41b112a4fc62fe1a6525 diff --git a/go/go.mod b/go/go.mod index 245af62..027b311 100644 --- a/go/go.mod +++ b/go/go.mod @@ -33,7 +33,7 @@ require ( ) require ( - dappco.re/go v0.9.0 + dappco.re/go v0.10.3 github.com/bytedance/gopkg v0.1.4 // indirect github.com/bytedance/sonic v1.15.0 // indirect github.com/bytedance/sonic/loader v0.5.0 // indirect diff --git a/go/go.sum b/go/go.sum index 33904c6..69b34c2 100644 --- a/go/go.sum +++ b/go/go.sum @@ -1,5 +1,7 @@ dappco.re/go v0.9.0 h1:4ruZRNqKDDva8o6g65tYggjGVe42E6/lMZfVKXtr3p0= dappco.re/go v0.9.0/go.mod h1:xapr7fLK4/9Pu2iSCr4qZuIuatmtx1j56zS/oPDbGyQ= +dappco.re/go v0.10.3 h1:aViRNxdg2jG84P6RsiD+aSta+GcFJwGXMNQPjFPbJ9g= +dappco.re/go v0.10.3/go.mod h1:xapr7fLK4/9Pu2iSCr4qZuIuatmtx1j56zS/oPDbGyQ= dappco.re/go/io v0.9.0 h1:TyHUuUJdZ73CXQlBpqx47SNyFFzgwA5OPSKu4Twb2f0= dappco.re/go/io v0.9.0/go.mod h1:K5jWSLMdk0X9HqJ6b1I+8tKqcNpNWgpcUZi/fGm28Q8= dappco.re/go/log v0.9.0 h1:9+OiBUDyUNvqZZ++XemcjJPCgypr+Yf/1e5OP3X2nrk= From 0046d2f91b478a61dd48fd50ad961b5767d091c7 Mon Sep 17 00:00:00 2001 From: Snider Date: Mon, 1 Jun 2026 00:44:33 +0100 Subject: [PATCH 09/19] test(agentic): cover plan CRUD, dispatch helpers, queue/status round-trips Adds Good/Bad/Ugly coverage for the agentic subsystem's file-backed and pure-helper surfaces that were previously at 0%: - plan create/read/update/delete/list lifecycle + checkpoint validation, validPlanStatus, generatePlanID slug/cap behaviour - parsePositiveInt, itoa, parseRetryAfter, repoRootFromCodePath, shellQuote, shellJoin, baseAgent, parseSimpleInt, countFindingHints, sanitizeFilename, trimDashes, listLocalRepos, buildPRBody - loadAgentsConfig (default + YAML), delayForAgent, listWorkspaceDirs, countRunningByAgent, canDispatchAgent, readStatus round-trip + malformed Package coverage 22.8% -> 34.1%. Additive tests only; no production change. Co-Authored-By: Virgil --- go/pkg/mcp/agentic/helpers_test.go | 220 ++++++++++++++ go/pkg/mcp/agentic/plan_crud_test.go | 384 ++++++++++++++++++++++++ go/pkg/mcp/agentic/queue_status_test.go | 234 +++++++++++++++ 3 files changed, 838 insertions(+) create mode 100644 go/pkg/mcp/agentic/helpers_test.go create mode 100644 go/pkg/mcp/agentic/plan_crud_test.go create mode 100644 go/pkg/mcp/agentic/queue_status_test.go diff --git a/go/pkg/mcp/agentic/helpers_test.go b/go/pkg/mcp/agentic/helpers_test.go new file mode 100644 index 0000000..c2bbb54 --- /dev/null +++ b/go/pkg/mcp/agentic/helpers_test.go @@ -0,0 +1,220 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package agentic + +import ( + "testing" + "time" + + core "dappco.re/go" +) + +func TestHelpers_parsePositiveInt_Good(t *testing.T) { + cases := map[string]int{ + "0": 0, + "7": 7, + " 42 ": 42, + "1000": 1000, + } + for in, want := range cases { + got, err := parsePositiveInt(in) + if err != nil { + t.Fatalf("parsePositiveInt(%q): unexpected error %v", in, err) + } + if got != want { + t.Fatalf("parsePositiveInt(%q) = %d, want %d", in, got, want) + } + } +} + +func TestHelpers_parsePositiveInt_Bad_Empty(t *testing.T) { + if _, err := parsePositiveInt(" "); err == nil { + t.Fatal("expected error for empty value") + } +} + +func TestHelpers_parsePositiveInt_Ugly_NonNumeric(t *testing.T) { + for _, in := range []string{"12a", "-5", "3.14", "abc"} { + if _, err := parsePositiveInt(in); err == nil { + t.Fatalf("expected error for %q", in) + } + } +} + +func TestHelpers_itoa_Good(t *testing.T) { + if itoa(0) != "0" { + t.Fatalf("itoa(0) = %q", itoa(0)) + } + if itoa(-12) != "-12" { + t.Fatalf("itoa(-12) = %q", itoa(-12)) + } + if itoa(2048) != "2048" { + t.Fatalf("itoa(2048) = %q", itoa(2048)) + } +} + +func TestHelpers_parseRetryAfter_Good_Units(t *testing.T) { + cases := map[string]time.Duration{ + "retry in 3 minutes": 3 * time.Minute, + "wait 2 hours": 2 * time.Hour, + "try 45 seconds": 45 * time.Second, + "30 SECONDS please": 30 * time.Second, + } + for in, want := range cases { + if got := parseRetryAfter(in); got != want { + t.Fatalf("parseRetryAfter(%q) = %v, want %v", in, got, want) + } + } +} + +func TestHelpers_parseRetryAfter_Ugly_NoMatchDefaults(t *testing.T) { + for _, in := range []string{"", "no number here", "soon", "0 minutes"} { + if got := parseRetryAfter(in); got != 5*time.Minute { + t.Fatalf("parseRetryAfter(%q) = %v, want default 5m", in, got) + } + } +} + +func TestHelpers_repoRootFromCodePath_Good(t *testing.T) { + got := repoRootFromCodePath("/home/agent/work") + want := core.Path("/home/agent/work", "core") + if got != want { + t.Fatalf("repoRootFromCodePath = %q, want %q", got, want) + } +} + +func TestHelpers_shellQuote_Good_Bad_Ugly(t *testing.T) { + if shellQuote("") != "''" { + t.Fatalf("empty value should quote to '', got %q", shellQuote("")) + } + if shellQuote("simple") != "'simple'" { + t.Fatalf("simple value quote = %q", shellQuote("simple")) + } + // Embedded single quote must be escaped using the '"'"' idiom. + got := shellQuote("a'b") + if got != `'a'"'"'b'` { + t.Fatalf("single-quote escape = %q", got) + } +} + +func TestHelpers_shellJoin_Good(t *testing.T) { + got := shellJoin("git", "rev-list", "--count", "a..b") + want := "'git' 'rev-list' '--count' 'a..b'" + if got != want { + t.Fatalf("shellJoin = %q, want %q", got, want) + } +} + +func TestHelpers_baseAgent_Good(t *testing.T) { + cases := map[string]string{ + "codex": "codex", + "codex:gpt-5.4": "codex", + "claude:opus:extra": "claude", + "": "", + } + for in, want := range cases { + if got := baseAgent(in); got != want { + t.Fatalf("baseAgent(%q) = %q, want %q", in, got, want) + } + } +} + +func TestHelpers_parseSimpleInt_Good_Bad(t *testing.T) { + if n, ok := parseSimpleInt(" 12 "); !ok || n != 12 { + t.Fatalf("parseSimpleInt(\" 12 \") = (%d,%v)", n, ok) + } + for _, in := range []string{"", "12x", "-1", "1.5"} { + if _, ok := parseSimpleInt(in); ok { + t.Fatalf("expected parseSimpleInt(%q) to fail", in) + } + } +} + +func TestHelpers_countFindingHints_Good(t *testing.T) { + output := "pkg/mcp/foo.go:12 has an issue\nsrc/app.ts:340 another\nplain prose with no ref\nlib/x.py:7 third" + if got := countFindingHints(output); got != 3 { + t.Fatalf("countFindingHints = %d, want 3", got) + } +} + +func TestHelpers_countFindingHints_Ugly_NoMatches(t *testing.T) { + if got := countFindingHints("nothing here resembles a file ref"); got != 0 { + t.Fatalf("countFindingHints = %d, want 0", got) + } +} + +func TestHelpers_sanitizeFilename_Good_Ugly(t *testing.T) { + // Allowed chars survive; everything else becomes a dash. + if got := sanitizeFilename("My_Plan-v1.2"); got != "My_Plan-v1.2" { + t.Fatalf("sanitizeFilename allowed-set = %q", got) + } + if got := sanitizeFilename("a b/c\\d"); got != "a-b-c-d" { + t.Fatalf("sanitizeFilename = %q, want a-b-c-d", got) + } +} + +func TestHelpers_trimDashes_Good(t *testing.T) { + cases := map[string]string{ + "--abc--": "abc", + "abc": "abc", + "---": "", + "-a-b-": "a-b", + } + for in, want := range cases { + if got := trimDashes(in); got != want { + t.Fatalf("trimDashes(%q) = %q, want %q", in, got, want) + } + } +} + +func TestHelpers_listLocalRepos_Good(t *testing.T) { + root := t.TempDir() + // Two directories (repos) and one file (ignored). + if err := coreio.Local.EnsureDir(core.Path(root, "repo-a")); err != nil { + t.Fatalf("mkdir repo-a: %v", err) + } + if err := coreio.Local.EnsureDir(core.Path(root, "repo-b")); err != nil { + t.Fatalf("mkdir repo-b: %v", err) + } + if err := writeAtomic(core.Path(root, "loose.txt"), "x"); err != nil { + t.Fatalf("write loose file: %v", err) + } + + repos := listLocalRepos(root) + if len(repos) != 2 { + t.Fatalf("expected 2 repos, got %d (%v)", len(repos), repos) + } +} + +func TestHelpers_listLocalRepos_Ugly_MissingDir(t *testing.T) { + if repos := listLocalRepos(core.Path(t.TempDir(), "does-not-exist")); repos != nil { + t.Fatalf("expected nil for missing dir, got %v", repos) + } +} + +func TestHelpers_buildPRBody_Good(t *testing.T) { + sub := &PrepSubsystem{} + body := sub.buildPRBody(&WorkspaceStatus{ + Task: "Implement notifications", + Issue: 1490, + Agent: "codex", + Runs: 3, + }) + for _, want := range []string{"## Summary", "Implement notifications", "Closes #1490", "**Agent:** codex", "**Runs:** 3"} { + if !core.Contains(body, want) { + t.Fatalf("buildPRBody missing %q in:\n%s", want, body) + } + } +} + +func TestHelpers_buildPRBody_Ugly_MinimalStatus(t *testing.T) { + sub := &PrepSubsystem{} + body := sub.buildPRBody(&WorkspaceStatus{Agent: "claude"}) + // No task, no issue: those sections must be omitted. + if core.Contains(body, "Closes #") { + t.Fatalf("expected no Closes line for zero issue:\n%s", body) + } + if !core.Contains(body, "**Agent:** claude") { + t.Fatalf("expected agent line:\n%s", body) + } +} diff --git a/go/pkg/mcp/agentic/plan_crud_test.go b/go/pkg/mcp/agentic/plan_crud_test.go new file mode 100644 index 0000000..378d90d --- /dev/null +++ b/go/pkg/mcp/agentic/plan_crud_test.go @@ -0,0 +1,384 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package agentic + +import ( + "context" + "testing" + "time" +) + +// newPlanSub returns a PrepSubsystem rooted at a fresh temp dir. +func newPlanSub(t *testing.T) *PrepSubsystem { + t.Helper() + return &PrepSubsystem{codePath: t.TempDir()} +} + +func TestPlan_planCreate_Good(t *testing.T) { + sub := newPlanSub(t) + + _, out, err := sub.planCreate(context.Background(), nil, PlanCreateInput{ + Title: "Add Notifications", + Objective: "Broadcast MCP events", + Repo: "core/mcp", + Org: "core", + Phases: []Phase{ + {Name: "Implementation"}, + {Name: "Tests"}, + }, + Notes: "first cut", + }) + if err != nil { + t.Fatalf("planCreate: %v", err) + } + if !out.Success { + t.Fatal("expected success") + } + if out.ID == "" { + t.Fatal("expected non-empty id") + } + if out.Path == "" { + t.Fatal("expected non-empty path") + } + + // Round-trip: the plan should read back with defaulted phase fields. + got, err := readPlan(sub.plansDir(), out.ID) + if err != nil { + t.Fatalf("readPlan: %v", err) + } + if got.Status != "draft" { + t.Fatalf("expected status draft, got %q", got.Status) + } + if len(got.Phases) != 2 { + t.Fatalf("expected 2 phases, got %d", len(got.Phases)) + } + if got.Phases[0].Status != "pending" || got.Phases[1].Status != "pending" { + t.Fatalf("expected phases defaulted to pending, got %q/%q", got.Phases[0].Status, got.Phases[1].Status) + } + if got.Phases[0].Number != 1 || got.Phases[1].Number != 2 { + t.Fatalf("expected phase numbers 1,2; got %d,%d", got.Phases[0].Number, got.Phases[1].Number) + } +} + +func TestPlan_planCreate_Bad_MissingTitle(t *testing.T) { + sub := newPlanSub(t) + + if _, _, err := sub.planCreate(context.Background(), nil, PlanCreateInput{ + Objective: "no title here", + }); err == nil { + t.Fatal("expected error for missing title") + } +} + +func TestPlan_planCreate_Bad_MissingObjective(t *testing.T) { + sub := newPlanSub(t) + + if _, _, err := sub.planCreate(context.Background(), nil, PlanCreateInput{ + Title: "no objective here", + }); err == nil { + t.Fatal("expected error for missing objective") + } +} + +func TestPlan_planRead_Good(t *testing.T) { + sub := newPlanSub(t) + + _, created, err := sub.planCreate(context.Background(), nil, PlanCreateInput{ + Title: "Readable Plan", + Objective: "Be readable", + }) + if err != nil { + t.Fatalf("planCreate: %v", err) + } + + _, out, err := sub.planRead(context.Background(), nil, PlanReadInput{ID: created.ID}) + if err != nil { + t.Fatalf("planRead: %v", err) + } + if !out.Success { + t.Fatal("expected success") + } + if out.Plan.Title != "Readable Plan" { + t.Fatalf("unexpected title %q", out.Plan.Title) + } +} + +func TestPlan_planRead_Bad_MissingID(t *testing.T) { + sub := newPlanSub(t) + + if _, _, err := sub.planRead(context.Background(), nil, PlanReadInput{}); err == nil { + t.Fatal("expected error for missing id") + } +} + +func TestPlan_planRead_Ugly_NotFound(t *testing.T) { + sub := newPlanSub(t) + + if _, _, err := sub.planRead(context.Background(), nil, PlanReadInput{ID: "does-not-exist"}); err == nil { + t.Fatal("expected error for unknown plan id") + } +} + +func TestPlan_planUpdate_Good_PartialFields(t *testing.T) { + sub := newPlanSub(t) + + _, created, err := sub.planCreate(context.Background(), nil, PlanCreateInput{ + Title: "Updatable", + Objective: "original objective", + }) + if err != nil { + t.Fatalf("planCreate: %v", err) + } + + _, out, err := sub.planUpdate(context.Background(), nil, PlanUpdateInput{ + ID: created.ID, + Status: "ready", + Objective: "new objective", + Agent: "hephaestus", + Notes: "updated", + }) + if err != nil { + t.Fatalf("planUpdate: %v", err) + } + if out.Plan.Status != "ready" { + t.Fatalf("expected status ready, got %q", out.Plan.Status) + } + if out.Plan.Objective != "new objective" { + t.Fatalf("expected objective updated, got %q", out.Plan.Objective) + } + if out.Plan.Agent != "hephaestus" { + t.Fatalf("expected agent set, got %q", out.Plan.Agent) + } + // Title was not provided; it must be preserved. + if out.Plan.Title != "Updatable" { + t.Fatalf("expected title preserved, got %q", out.Plan.Title) + } +} + +func TestPlan_planUpdate_Bad_MissingID(t *testing.T) { + sub := newPlanSub(t) + + if _, _, err := sub.planUpdate(context.Background(), nil, PlanUpdateInput{Status: "ready"}); err == nil { + t.Fatal("expected error for missing id") + } +} + +func TestPlan_planUpdate_Ugly_InvalidStatus(t *testing.T) { + sub := newPlanSub(t) + + _, created, err := sub.planCreate(context.Background(), nil, PlanCreateInput{ + Title: "Status Guard", + Objective: "reject bad status", + }) + if err != nil { + t.Fatalf("planCreate: %v", err) + } + + if _, _, err := sub.planUpdate(context.Background(), nil, PlanUpdateInput{ + ID: created.ID, + Status: "totally-not-valid", + }); err == nil { + t.Fatal("expected error for invalid status") + } +} + +func TestPlan_planUpdate_Ugly_NotFound(t *testing.T) { + sub := newPlanSub(t) + + if _, _, err := sub.planUpdate(context.Background(), nil, PlanUpdateInput{ + ID: "nope", + Notes: "x", + }); err == nil { + t.Fatal("expected error for unknown plan id") + } +} + +func TestPlan_planDelete_Good(t *testing.T) { + sub := newPlanSub(t) + + _, created, err := sub.planCreate(context.Background(), nil, PlanCreateInput{ + Title: "Doomed", + Objective: "to be deleted", + }) + if err != nil { + t.Fatalf("planCreate: %v", err) + } + + _, out, err := sub.planDelete(context.Background(), nil, PlanDeleteInput{ID: created.ID}) + if err != nil { + t.Fatalf("planDelete: %v", err) + } + if out.Deleted != created.ID { + t.Fatalf("expected deleted %q, got %q", created.ID, out.Deleted) + } + + // A second read must now fail. + if _, _, err := sub.planRead(context.Background(), nil, PlanReadInput{ID: created.ID}); err == nil { + t.Fatal("expected read of deleted plan to fail") + } +} + +func TestPlan_planDelete_Bad_MissingID(t *testing.T) { + sub := newPlanSub(t) + + if _, _, err := sub.planDelete(context.Background(), nil, PlanDeleteInput{}); err == nil { + t.Fatal("expected error for missing id") + } +} + +func TestPlan_planDelete_Ugly_NotFound(t *testing.T) { + sub := newPlanSub(t) + + if _, _, err := sub.planDelete(context.Background(), nil, PlanDeleteInput{ID: "ghost"}); err == nil { + t.Fatal("expected error for unknown plan id") + } +} + +func TestPlan_planList_Good_FiltersByStatusAndRepo(t *testing.T) { + sub := newPlanSub(t) + ctx := context.Background() + + // Three plans: two draft (one in core/mcp), one ready. + if _, _, err := sub.planCreate(ctx, nil, PlanCreateInput{Title: "A draft mcp", Objective: "o", Repo: "core/mcp"}); err != nil { + t.Fatalf("create A: %v", err) + } + if _, _, err := sub.planCreate(ctx, nil, PlanCreateInput{Title: "B draft other", Objective: "o", Repo: "core/api"}); err != nil { + t.Fatalf("create B: %v", err) + } + _, readyPlan, err := sub.planCreate(ctx, nil, PlanCreateInput{Title: "C plan", Objective: "o", Repo: "core/mcp"}) + if err != nil { + t.Fatalf("create C: %v", err) + } + if _, _, err := sub.planUpdate(ctx, nil, PlanUpdateInput{ID: readyPlan.ID, Status: "ready"}); err != nil { + t.Fatalf("update C: %v", err) + } + + // No filter: all three. + _, all, err := sub.planList(ctx, nil, PlanListInput{}) + if err != nil { + t.Fatalf("planList all: %v", err) + } + if all.Count != 3 { + t.Fatalf("expected 3 plans, got %d", all.Count) + } + + // Status filter. + _, drafts, err := sub.planList(ctx, nil, PlanListInput{Status: "draft"}) + if err != nil { + t.Fatalf("planList draft: %v", err) + } + if drafts.Count != 2 { + t.Fatalf("expected 2 draft plans, got %d", drafts.Count) + } + + // Repo filter. + _, mcpPlans, err := sub.planList(ctx, nil, PlanListInput{Repo: "core/mcp"}) + if err != nil { + t.Fatalf("planList repo: %v", err) + } + if mcpPlans.Count != 2 { + t.Fatalf("expected 2 core/mcp plans, got %d", mcpPlans.Count) + } +} + +func TestPlan_planList_Ugly_EmptyDir(t *testing.T) { + sub := newPlanSub(t) + + _, out, err := sub.planList(context.Background(), nil, PlanListInput{}) + if err != nil { + t.Fatalf("planList on empty dir: %v", err) + } + if out.Count != 0 { + t.Fatalf("expected 0 plans, got %d", out.Count) + } +} + +func TestPlan_planCheckpoint_Bad_MissingID(t *testing.T) { + sub := newPlanSub(t) + + if _, _, err := sub.planCheckpoint(context.Background(), nil, PlanCheckpointInput{Phase: 1, Done: true}); err == nil { + t.Fatal("expected error for missing id") + } +} + +func TestPlan_planCheckpoint_Bad_NonPositivePhase(t *testing.T) { + sub := newPlanSub(t) + + if _, _, err := sub.planCheckpoint(context.Background(), nil, PlanCheckpointInput{ID: "x", Phase: 0, Done: true}); err == nil { + t.Fatal("expected error for non-positive phase") + } +} + +func TestPlan_planCheckpoint_Bad_NoNotesNoDone(t *testing.T) { + sub := newPlanSub(t) + + if _, _, err := sub.planCheckpoint(context.Background(), nil, PlanCheckpointInput{ID: "x", Phase: 1}); err == nil { + t.Fatal("expected error when neither notes nor done provided") + } +} + +func TestPlan_planCheckpoint_Ugly_PhaseOutOfRange(t *testing.T) { + sub := newPlanSub(t) + + plan := &Plan{ + ID: "single-phase", + Title: "Single", + Status: "in_progress", + Objective: "o", + Phases: []Phase{{Number: 1, Name: "Phase 1", Status: "pending"}}, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + if _, err := writePlan(sub.plansDir(), plan); err != nil { + t.Fatalf("writePlan: %v", err) + } + + if _, _, err := sub.planCheckpoint(context.Background(), nil, PlanCheckpointInput{ + ID: plan.ID, + Phase: 5, + Done: true, + }); err == nil { + t.Fatal("expected error for phase out of range") + } +} + +func TestPlan_validPlanStatus_Good_Bad(t *testing.T) { + valid := []string{"draft", "ready", "in_progress", "needs_verification", "verified", "approved"} + for _, s := range valid { + if !validPlanStatus(s) { + t.Fatalf("expected %q to be valid", s) + } + } + for _, s := range []string{"", "done", "DRAFT", "approved "} { + if validPlanStatus(s) { + t.Fatalf("expected %q to be invalid", s) + } + } +} + +func TestPlan_generatePlanID_Good_SlugAndUnique(t *testing.T) { + id := generatePlanID("Add Notifications!! To The System") + // Punctuation dropped, spaces become single dashes, lower-cased, suffix appended. + if id == "" { + t.Fatal("expected non-empty id") + } + for _, r := range id { + ok := (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' + if !ok { + t.Fatalf("unexpected rune %q in id %q", r, id) + } + } + // Two calls with the same title must differ (random suffix). + if id == generatePlanID("Add Notifications!! To The System") { + t.Fatal("expected unique ids across calls") + } +} + +func TestPlan_generatePlanID_Ugly_LongTitleCapped(t *testing.T) { + long := "This Is An Extremely Long Plan Title That Should Be Capped For Filesystem Safety" + id := generatePlanID(long) + // slug capped at 30, plus "-" and 6 hex chars = 37 max. + if len(id) > 37 { + t.Fatalf("expected id length <= 37, got %d (%q)", len(id), id) + } +} diff --git a/go/pkg/mcp/agentic/queue_status_test.go b/go/pkg/mcp/agentic/queue_status_test.go new file mode 100644 index 0000000..285881a --- /dev/null +++ b/go/pkg/mcp/agentic/queue_status_test.go @@ -0,0 +1,234 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package agentic + +import ( + "testing" + "time" + + core "dappco.re/go" +) + +// writeWorkspaceStatus seeds a workspace directory under the prep root with a +// status.json describing a tracked run. +func writeWorkspaceStatus(t *testing.T, sub *PrepSubsystem, name string, st *WorkspaceStatus) string { + t.Helper() + wsDir := core.Path(sub.workspaceRoot(), name) + if err := coreio.Local.EnsureDir(wsDir); err != nil { + t.Fatalf("EnsureDir %s: %v", wsDir, err) + } + sub.saveStatus(wsDir, st) + if !coreio.Local.IsFile(core.Path(wsDir, "status.json")) { + t.Fatalf("expected status.json to be written for %s", name) + } + return wsDir +} + +func TestQueue_loadAgentsConfig_Ugly_DefaultsWhenMissing(t *testing.T) { + sub := newPlanSub(t) // empty temp codePath, no agents.yaml + + cfg := sub.loadAgentsConfig() + if cfg == nil { + t.Fatal("expected non-nil default config") + } + if cfg.Dispatch.DefaultAgent != "claude" { + t.Fatalf("expected default agent claude, got %q", cfg.Dispatch.DefaultAgent) + } + if cfg.Concurrency["claude"] != 1 { + t.Fatalf("expected claude concurrency 1, got %d", cfg.Concurrency["claude"]) + } +} + +func TestQueue_loadAgentsConfig_Good_ParsesYAML(t *testing.T) { + sub := newPlanSub(t) + cfgDir := core.Path(sub.codePath, ".core") + if err := coreio.Local.EnsureDir(cfgDir); err != nil { + t.Fatalf("EnsureDir: %v", err) + } + yamlBody := "version: 1\n" + + "dispatch:\n" + + " default_agent: codex\n" + + " default_template: security\n" + + "concurrency:\n" + + " codex: 4\n" + + "rates:\n" + + " codex:\n" + + " reset_utc: \"06:00\"\n" + + " sustained_delay: 30\n" + + " burst_window: 2\n" + + " burst_delay: 5\n" + if err := writeAtomic(core.Path(cfgDir, "agents.yaml"), yamlBody); err != nil { + t.Fatalf("write agents.yaml: %v", err) + } + + cfg := sub.loadAgentsConfig() + if cfg.Dispatch.DefaultAgent != "codex" { + t.Fatalf("expected codex, got %q", cfg.Dispatch.DefaultAgent) + } + if cfg.Concurrency["codex"] != 4 { + t.Fatalf("expected codex concurrency 4, got %d", cfg.Concurrency["codex"]) + } + if cfg.Rates["codex"].SustainedDelay != 30 { + t.Fatalf("expected sustained_delay 30, got %d", cfg.Rates["codex"].SustainedDelay) + } +} + +func TestQueue_delayForAgent_Ugly_NoRateConfigZero(t *testing.T) { + sub := newPlanSub(t) + // Default config has no rates → zero delay. + if d := sub.delayForAgent("claude"); d != 0 { + t.Fatalf("expected zero delay with no rate config, got %v", d) + } +} + +func TestQueue_delayForAgent_Good_SustainedDelay(t *testing.T) { + sub := newPlanSub(t) + cfgDir := core.Path(sub.codePath, ".core") + if err := coreio.Local.EnsureDir(cfgDir); err != nil { + t.Fatalf("EnsureDir: %v", err) + } + // burst_window 0 disables burst, so we always get sustained_delay. + yamlBody := "dispatch:\n default_agent: codex\n" + + "rates:\n codex:\n reset_utc: \"06:00\"\n sustained_delay: 45\n burst_window: 0\n" + if err := writeAtomic(core.Path(cfgDir, "agents.yaml"), yamlBody); err != nil { + t.Fatalf("write agents.yaml: %v", err) + } + + if d := sub.delayForAgent("codex"); d != 45*time.Second { + t.Fatalf("expected 45s sustained delay, got %v", d) + } +} + +func TestQueue_listWorkspaceDirs_Good_FlatAndNested(t *testing.T) { + sub := newPlanSub(t) + + // Flat workspace with status.json. + writeWorkspaceStatus(t, sub, "go-mcp-1", &WorkspaceStatus{Status: "running", Agent: "codex"}) + + // Nested workspace: /core/go-io-2/status.json. + nested := core.Path(sub.workspaceRoot(), "core", "go-io-2") + if err := coreio.Local.EnsureDir(nested); err != nil { + t.Fatalf("EnsureDir nested: %v", err) + } + sub.saveStatus(nested, &WorkspaceStatus{Status: "completed", Agent: "claude"}) + + dirs := sub.listWorkspaceDirs() + if len(dirs) != 2 { + t.Fatalf("expected 2 workspace dirs, got %d (%v)", len(dirs), dirs) + } +} + +func TestQueue_listWorkspaceDirs_Ugly_NoRoot(t *testing.T) { + sub := newPlanSub(t) + // workspaceRoot does not exist yet. + if dirs := sub.listWorkspaceDirs(); dirs != nil { + t.Fatalf("expected nil for missing workspace root, got %v", dirs) + } +} + +func TestQueue_countRunningByAgent_Good_SelfPIDCounts(t *testing.T) { + sub := newPlanSub(t) + // A running workspace whose PID is this test process — kill(pid,0) succeeds. + writeWorkspaceStatus(t, sub, "live", &WorkspaceStatus{ + Status: "running", + Agent: "codex:gpt-5.4", + PID: core.Getpid(), + }) + // A running workspace for a different agent — should not count. + writeWorkspaceStatus(t, sub, "other", &WorkspaceStatus{ + Status: "running", + Agent: "claude", + PID: core.Getpid(), + }) + // A completed workspace — should not count even for codex. + writeWorkspaceStatus(t, sub, "done", &WorkspaceStatus{ + Status: "completed", + Agent: "codex", + PID: core.Getpid(), + }) + + if n := sub.countRunningByAgent("codex"); n != 1 { + t.Fatalf("expected 1 running codex workspace, got %d", n) + } +} + +func TestQueue_canDispatchAgent_Good_UnderLimit(t *testing.T) { + sub := newPlanSub(t) + cfgDir := core.Path(sub.codePath, ".core") + if err := coreio.Local.EnsureDir(cfgDir); err != nil { + t.Fatalf("EnsureDir: %v", err) + } + if err := writeAtomic(core.Path(cfgDir, "agents.yaml"), + "concurrency:\n codex: 2\n"); err != nil { + t.Fatalf("write agents.yaml: %v", err) + } + + // One running codex run, limit 2 → can dispatch. + writeWorkspaceStatus(t, sub, "live", &WorkspaceStatus{ + Status: "running", + Agent: "codex", + PID: core.Getpid(), + }) + if !sub.canDispatchAgent("codex") { + t.Fatal("expected canDispatchAgent true when under limit") + } +} + +func TestQueue_canDispatchAgent_Ugly_AtLimitBlocks(t *testing.T) { + sub := newPlanSub(t) + cfgDir := core.Path(sub.codePath, ".core") + if err := coreio.Local.EnsureDir(cfgDir); err != nil { + t.Fatalf("EnsureDir: %v", err) + } + if err := writeAtomic(core.Path(cfgDir, "agents.yaml"), + "concurrency:\n codex: 1\n"); err != nil { + t.Fatalf("write agents.yaml: %v", err) + } + + writeWorkspaceStatus(t, sub, "live", &WorkspaceStatus{ + Status: "running", + Agent: "codex", + PID: core.Getpid(), + }) + if sub.canDispatchAgent("codex") { + t.Fatal("expected canDispatchAgent false at limit") + } +} + +func TestStatus_readStatus_RoundTrip_Good(t *testing.T) { + sub := newPlanSub(t) + wsDir := writeWorkspaceStatus(t, sub, "rt", &WorkspaceStatus{ + Status: "blocked", + Agent: "codex", + Repo: "core/mcp", + Task: "do the thing", + Issue: 42, + }) + + st, err := readStatus(wsDir) + if err != nil { + t.Fatalf("readStatus: %v", err) + } + if st.Status != "blocked" || st.Repo != "core/mcp" || st.Issue != 42 { + t.Fatalf("round-trip mismatch: %+v", st) + } + if st.UpdatedAt.IsZero() { + t.Fatal("expected UpdatedAt to be stamped by saveStatus") + } +} + +func TestStatus_readStatus_Bad_Missing(t *testing.T) { + if _, err := readStatus(t.TempDir()); err == nil { + t.Fatal("expected error reading status from dir with no status.json") + } +} + +func TestStatus_readStatus_Ugly_Malformed(t *testing.T) { + dir := t.TempDir() + if err := writeAtomic(core.JoinPath(dir, "status.json"), "{not valid json"); err != nil { + t.Fatalf("write malformed: %v", err) + } + if _, err := readStatus(dir); err == nil { + t.Fatal("expected error parsing malformed status.json") + } +} From e122a7211edccbcf576e3f2f0eaf93ac7a46d7ac Mon Sep 17 00:00:00 2001 From: Snider Date: Mon, 1 Jun 2026 00:46:54 +0100 Subject: [PATCH 10/19] test(mcp): cover metrics record/query and workspace file ops Adds Good/Bad/Ugly coverage for previously-0% pkg/mcp surfaces: - metricsRecord/metricsQuery handlers, recordMetricEvent, readMetricEvents, summarizeMetricEvents, convertMetricCounts (in-memory event store) - createDirectory/deleteFile/renameFile/fileExists/editDiff workspace-medium handlers (single + replace-all + not-found + empty-old-string paths) - detectLanguage handler + getSupportedLanguages, countOccurrences, replaceFirst pure helpers Package coverage 61.8% -> 65.5%. Additive tests only; no production change. Co-Authored-By: Virgil --- go/pkg/mcp/tools_files_test.go | 254 +++++++++++++++++++++ go/pkg/mcp/tools_metrics_behaviour_test.go | 183 +++++++++++++++ 2 files changed, 437 insertions(+) create mode 100644 go/pkg/mcp/tools_files_test.go create mode 100644 go/pkg/mcp/tools_metrics_behaviour_test.go diff --git a/go/pkg/mcp/tools_files_test.go b/go/pkg/mcp/tools_files_test.go new file mode 100644 index 0000000..7af6079 --- /dev/null +++ b/go/pkg/mcp/tools_files_test.go @@ -0,0 +1,254 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package mcp + +import ( + "context" + "testing" +) + +// newTmpService returns a workspace-medium-backed Service rooted at a temp dir. +func newTmpService(t *testing.T) *Service { + t.Helper() + s, err := New(Options{WorkspaceRoot: t.TempDir()}) + if err != nil { + t.Fatalf("New: %v", err) + } + if s.medium == nil { + t.Fatal("expected workspace medium to be set") + } + return s +} + +func TestFiles_createDirectory_Good(t *testing.T) { + s := newTmpService(t) + ctx := context.Background() + + _, out, err := s.createDirectory(ctx, nil, CreateDirectoryInput{Path: "sub/dir"}) + if err != nil { + t.Fatalf("createDirectory: %v", err) + } + if !out.Success || out.Path != "sub/dir" { + t.Fatalf("unexpected output: %+v", out) + } + + _, exists, err := s.fileExists(ctx, nil, FileExistsInput{Path: "sub/dir"}) + if err != nil { + t.Fatalf("fileExists: %v", err) + } + if !exists.Exists || !exists.IsDir { + t.Fatalf("expected created directory to exist: %+v", exists) + } +} + +func TestFiles_deleteFile_Good(t *testing.T) { + s := newTmpService(t) + ctx := context.Background() + + if err := s.medium.Write("doomed.txt", "bye"); err != nil { + t.Fatalf("seed write: %v", err) + } + + _, out, err := s.deleteFile(ctx, nil, DeleteFileInput{Path: "doomed.txt"}) + if err != nil { + t.Fatalf("deleteFile: %v", err) + } + if !out.Success { + t.Fatal("expected success") + } + if s.medium.Exists("doomed.txt") { + t.Fatal("expected file to be gone after delete") + } +} + +func TestFiles_renameFile_Good(t *testing.T) { + s := newTmpService(t) + ctx := context.Background() + + if err := s.medium.Write("from.txt", "content"); err != nil { + t.Fatalf("seed write: %v", err) + } + + _, out, err := s.renameFile(ctx, nil, RenameFileInput{OldPath: "from.txt", NewPath: "to.txt"}) + if err != nil { + t.Fatalf("renameFile: %v", err) + } + if !out.Success || out.NewPath != "to.txt" { + t.Fatalf("unexpected output: %+v", out) + } + if s.medium.Exists("from.txt") { + t.Fatal("expected old path to be gone") + } + if !s.medium.Exists("to.txt") { + t.Fatal("expected new path to exist") + } +} + +func TestFiles_fileExists_Ugly_Missing(t *testing.T) { + s := newTmpService(t) + _, out, err := s.fileExists(context.Background(), nil, FileExistsInput{Path: "nope.txt"}) + if err != nil { + t.Fatalf("fileExists: %v", err) + } + if out.Exists { + t.Fatal("expected Exists false for missing path") + } +} + +func TestFiles_editDiff_Good_SingleReplace(t *testing.T) { + s := newTmpService(t) + ctx := context.Background() + + if err := s.medium.Write("code.go", "alpha beta alpha"); err != nil { + t.Fatalf("seed write: %v", err) + } + + _, out, err := s.editDiff(ctx, nil, EditDiffInput{ + Path: "code.go", + OldString: "alpha", + NewString: "gamma", + }) + if err != nil { + t.Fatalf("editDiff: %v", err) + } + if out.Replacements != 1 { + t.Fatalf("expected 1 replacement, got %d", out.Replacements) + } + + got, err := s.medium.Read("code.go") + if err != nil { + t.Fatalf("read back: %v", err) + } + if got != "gamma beta alpha" { + t.Fatalf("expected only first occurrence replaced, got %q", got) + } +} + +func TestFiles_editDiff_Good_ReplaceAll(t *testing.T) { + s := newTmpService(t) + ctx := context.Background() + + if err := s.medium.Write("code.go", "x y x z x"); err != nil { + t.Fatalf("seed write: %v", err) + } + + _, out, err := s.editDiff(ctx, nil, EditDiffInput{ + Path: "code.go", + OldString: "x", + NewString: "Q", + ReplaceAll: true, + }) + if err != nil { + t.Fatalf("editDiff: %v", err) + } + if out.Replacements != 3 { + t.Fatalf("expected 3 replacements, got %d", out.Replacements) + } + got, _ := s.medium.Read("code.go") + if got != "Q y Q z Q" { + t.Fatalf("unexpected content %q", got) + } +} + +func TestFiles_editDiff_Bad_EmptyOldString(t *testing.T) { + s := newTmpService(t) + if err := s.medium.Write("code.go", "data"); err != nil { + t.Fatalf("seed write: %v", err) + } + if _, _, err := s.editDiff(context.Background(), nil, EditDiffInput{ + Path: "code.go", + OldString: "", + NewString: "x", + }); err == nil { + t.Fatal("expected error for empty old_string") + } +} + +func TestFiles_editDiff_Ugly_NotFound(t *testing.T) { + s := newTmpService(t) + if err := s.medium.Write("code.go", "hello world"); err != nil { + t.Fatalf("seed write: %v", err) + } + if _, _, err := s.editDiff(context.Background(), nil, EditDiffInput{ + Path: "code.go", + OldString: "absent", + NewString: "x", + }); err == nil { + t.Fatal("expected error when old_string is absent") + } +} + +func TestFiles_detectLanguage_Good(t *testing.T) { + s := newTmpService(t) + cases := map[string]string{ + "main.go": "go", + "app.ts": "typescript", + "index.tsx": "typescript", + "server.py": "python", + "lib.rs": "rust", + "Dockerfile": "dockerfile", + "deploy/Dockerfile": "dockerfile", + "notes.md": "markdown", + "unknown.zzz": "plaintext", + "noext": "plaintext", + } + for path, want := range cases { + _, out, err := s.detectLanguage(context.Background(), nil, DetectLanguageInput{Path: path}) + if err != nil { + t.Fatalf("detectLanguage(%q): %v", path, err) + } + if out.Language != want { + t.Fatalf("detectLanguage(%q) = %q, want %q", path, out.Language, want) + } + } +} + +func TestFiles_getSupportedLanguages_Good(t *testing.T) { + s := newTmpService(t) + _, out, err := s.getSupportedLanguages(context.Background(), nil, GetSupportedLanguagesInput{}) + if err != nil { + t.Fatalf("getSupportedLanguages: %v", err) + } + if len(out.Languages) == 0 { + t.Fatal("expected non-empty supported languages") + } + // "go" must be present with the .go extension. + found := false + for _, l := range out.Languages { + if l.ID == "go" { + found = true + if len(l.Extensions) == 0 || l.Extensions[0] != ".go" { + t.Fatalf("go language missing .go extension: %+v", l) + } + } + } + if !found { + t.Fatal("expected go language in supported set") + } +} + +func TestFiles_countOccurrences_Good_Bad(t *testing.T) { + if n := countOccurrences("aaa", "a"); n != 3 { + t.Fatalf("countOccurrences(aaa,a) = %d, want 3", n) + } + if n := countOccurrences("ababab", "ab"); n != 3 { + t.Fatalf("countOccurrences(ababab,ab) = %d, want 3", n) + } + // Empty substring returns 0 by contract. + if n := countOccurrences("anything", ""); n != 0 { + t.Fatalf("countOccurrences with empty substr = %d, want 0", n) + } + if n := countOccurrences("abc", "z"); n != 0 { + t.Fatalf("countOccurrences(abc,z) = %d, want 0", n) + } +} + +func TestFiles_replaceFirst_Good(t *testing.T) { + if got := replaceFirst("alpha beta alpha", "alpha", "X"); got != "X beta alpha" { + t.Fatalf("replaceFirst = %q", got) + } + // No match returns the original string. + if got := replaceFirst("hello", "zzz", "Q"); got != "hello" { + t.Fatalf("replaceFirst no-match = %q", got) + } +} diff --git a/go/pkg/mcp/tools_metrics_behaviour_test.go b/go/pkg/mcp/tools_metrics_behaviour_test.go new file mode 100644 index 0000000..ebfde25 --- /dev/null +++ b/go/pkg/mcp/tools_metrics_behaviour_test.go @@ -0,0 +1,183 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package mcp + +import ( + "context" + "testing" + "time" +) + +func TestMetrics_recordAndRead_Good(t *testing.T) { + // Capture a cut-off just before we record so the package-global event slice + // from other tests does not bleed into this query window. + since := time.Now() + time.Sleep(time.Millisecond) + + for _, ev := range []metricEvent{ + {Type: "dispatch.complete", AgentID: "cladius", Repo: "core/mcp"}, + {Type: "dispatch.complete", AgentID: "hephaestus", Repo: "core/mcp"}, + {Type: "dispatch.fail", AgentID: "cladius", Repo: "core/api"}, + } { + if err := recordMetricEvent(ev); err != nil { + t.Fatalf("recordMetricEvent: %v", err) + } + } + + got, err := readMetricEvents(since) + if err != nil { + t.Fatalf("readMetricEvents: %v", err) + } + if len(got) < 3 { + t.Fatalf("expected at least 3 events since cut-off, got %d", len(got)) + } + // recordMetricEvent stamps a timestamp when zero. + for _, e := range got { + if e.Timestamp.IsZero() { + t.Fatal("expected recorded event to have a non-zero timestamp") + } + } +} + +func TestMetrics_readMetricEvents_Ugly_FutureSinceEmpty(t *testing.T) { + if err := recordMetricEvent(metricEvent{Type: "x", Timestamp: time.Now()}); err != nil { + t.Fatalf("recordMetricEvent: %v", err) + } + // A cut-off in the future excludes everything. + got, err := readMetricEvents(time.Now().Add(time.Hour)) + if err != nil { + t.Fatalf("readMetricEvents: %v", err) + } + if len(got) != 0 { + t.Fatalf("expected 0 events for future cut-off, got %d", len(got)) + } +} + +func TestMetrics_summarizeMetricEvents_Good(t *testing.T) { + events := []metricEvent{ + {Type: "a", AgentID: "x", Repo: "r1"}, + {Type: "a", AgentID: "y", Repo: "r1"}, + {Type: "b", AgentID: "x", Repo: "r2"}, + {Type: "", AgentID: "", Repo: ""}, // empty fields are ignored + } + summary := summarizeMetricEvents(events) + + if total, _ := summary["total"].(int); total != 4 { + t.Fatalf("expected total 4, got %v", summary["total"]) + } + + byType := convertMetricCounts(summary["by_type"]) + counts := map[string]int{} + for _, mc := range byType { + counts[mc.Key] = mc.Count + } + if counts["a"] != 2 || counts["b"] != 1 { + t.Fatalf("unexpected by_type counts: %+v", counts) + } + if _, ok := counts[""]; ok { + t.Fatal("empty type should not be counted") + } + + byRepo := convertMetricCounts(summary["by_repo"]) + if len(byRepo) != 2 { + t.Fatalf("expected 2 repos, got %d", len(byRepo)) + } + byAgent := convertMetricCounts(summary["by_agent"]) + if len(byAgent) != 2 { + t.Fatalf("expected 2 agents, got %d", len(byAgent)) + } +} + +func TestMetrics_convertMetricCounts_Ugly_NilAndWrongType(t *testing.T) { + if got := convertMetricCounts(nil); len(got) != 0 { + t.Fatalf("expected empty slice for nil, got %d", len(got)) + } + if got := convertMetricCounts("not a slice"); len(got) != 0 { + t.Fatalf("expected empty slice for wrong type, got %d", len(got)) + } +} + +func TestMetrics_metricsRecord_Good(t *testing.T) { + s, err := New(Options{}) + if err != nil { + t.Fatalf("New: %v", err) + } + _, out, err := s.metricsRecord(context.Background(), nil, MetricsRecordInput{ + Type: "tool.call", + AgentID: "cladius", + Repo: "core/mcp", + Data: map[string]any{"tool": "file_read"}, + }) + if err != nil { + t.Fatalf("metricsRecord: %v", err) + } + if !out.Success { + t.Fatal("expected success") + } + if out.Timestamp.IsZero() { + t.Fatal("expected a server-assigned timestamp") + } +} + +func TestMetrics_metricsRecord_Bad_EmptyType(t *testing.T) { + s, err := New(Options{}) + if err != nil { + t.Fatalf("New: %v", err) + } + if _, _, err := s.metricsRecord(context.Background(), nil, MetricsRecordInput{ + AgentID: "cladius", + }); err == nil { + t.Fatal("expected error for empty type") + } +} + +func TestMetrics_metricsQuery_Good(t *testing.T) { + s, err := New(Options{}) + if err != nil { + t.Fatalf("New: %v", err) + } + + // Seed a couple of events through the public record handler. + for _, typ := range []string{"q.one", "q.one", "q.two"} { + if _, _, err := s.metricsRecord(context.Background(), nil, MetricsRecordInput{ + Type: typ, + AgentID: "cladius", + Repo: "core/mcp", + }); err != nil { + t.Fatalf("seed metricsRecord: %v", err) + } + } + + _, out, err := s.metricsQuery(context.Background(), nil, MetricsQueryInput{Since: "1h"}) + if err != nil { + t.Fatalf("metricsQuery: %v", err) + } + if out.Total < 3 { + t.Fatalf("expected at least 3 events in the last hour, got %d", out.Total) + } + // Events list is capped at DefaultMetricsLimit and ordered most-recent-first. + if len(out.Events) > DefaultMetricsLimit { + t.Fatalf("expected at most %d events, got %d", DefaultMetricsLimit, len(out.Events)) + } +} + +func TestMetrics_metricsQuery_Bad_InvalidSince(t *testing.T) { + s, err := New(Options{}) + if err != nil { + t.Fatalf("New: %v", err) + } + if _, _, err := s.metricsQuery(context.Background(), nil, MetricsQueryInput{Since: "not-a-duration"}); err == nil { + t.Fatal("expected error for invalid since value") + } +} + +func TestMetrics_metricsQuery_Good_DefaultSince(t *testing.T) { + s, err := New(Options{}) + if err != nil { + t.Fatalf("New: %v", err) + } + // Empty Since must fall back to DefaultMetricsSince without error. + if _, _, err := s.metricsQuery(context.Background(), nil, MetricsQueryInput{}); err != nil { + t.Fatalf("metricsQuery with default since: %v", err) + } +} From 9a0e580accb16396e2e96b097e76ccbec5e36717 Mon Sep 17 00:00:00 2001 From: Snider Date: Mon, 1 Jun 2026 00:49:22 +0100 Subject: [PATCH 11/19] test(mcp): cover transformer arg/text helpers and authz claim extraction Adds Good/Bad/Ugly coverage for previously-0% pkg/mcp helper surfaces: - mapFromAny (nil/map/RawMessage/string/struct + non-JSON _raw fallback), parseArgumentString, indexByte, indexAny, trimBytes, extractTextFromAny, normaliseAnthropicTools, normaliseOpenAITools (function + custom envelope) - claimsFromContext (present/absent/nil-ctx), withAuthClaims nil-ctx no-panic, authClaimsFromToolRequest (token-disabled / context-claims / direct-call), inputWorkspaceFromValue over struct/ptr/map/no-match (reflection guard) Package coverage 65.5% -> 69.0%. Additive tests only; no production change. Co-Authored-By: Virgil --- go/pkg/mcp/authz_helpers_test.go | 131 ++++++++++++++++++++++ go/pkg/mcp/transformer_helpers_test.go | 145 +++++++++++++++++++++++++ 2 files changed, 276 insertions(+) create mode 100644 go/pkg/mcp/authz_helpers_test.go create mode 100644 go/pkg/mcp/transformer_helpers_test.go diff --git a/go/pkg/mcp/authz_helpers_test.go b/go/pkg/mcp/authz_helpers_test.go new file mode 100644 index 0000000..c4281a4 --- /dev/null +++ b/go/pkg/mcp/authz_helpers_test.go @@ -0,0 +1,131 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package mcp + +import ( + "context" + "testing" +) + +func TestAuthzHelpers_claimsFromContext_Good_Bad_Ugly(t *testing.T) { + // Good: claims stored via withAuthClaims round-trip back out. + want := &authClaims{Workspace: "core/mcp", Subject: "cladius"} + ctx := withAuthClaims(context.Background(), want) + if got := claimsFromContext(ctx); got != want { + t.Fatalf("claimsFromContext = %v, want %v", got, want) + } + + // Bad: a context with no claims yields nil. + if got := claimsFromContext(context.Background()); got != nil { + t.Fatalf("expected nil claims for bare context, got %v", got) + } + + // Ugly: a nil context must not panic and yields nil. + if got := claimsFromContext(nil); got != nil { + t.Fatalf("expected nil claims for nil context, got %v", got) + } +} + +func TestAuthzHelpers_withAuthClaims_Ugly_NilContext(t *testing.T) { + // withAuthClaims on a nil context returns a usable background context but does + // NOT carry the claims (it bails before WithValue). The contract here is + // "do not panic on nil"; claims simply are not retrievable. + ctx := withAuthClaims(nil, &authClaims{Subject: "x"}) + if ctx == nil { + t.Fatal("expected non-nil context") + } + if claimsFromContext(ctx) != nil { + t.Fatal("expected no claims to be carried on a recovered nil context") + } +} + +func TestAuthzHelpers_authClaimsFromToolRequest_Good_NoTokenDisablesAuth(t *testing.T) { + // With an empty API token, auth is disabled: no claims, not in-transport, no error. + claims, inTransport, err := authClaimsFromToolRequest(context.Background(), nil, "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if claims != nil || inTransport { + t.Fatalf("expected disabled auth (nil, false), got (%v, %v)", claims, inTransport) + } +} + +func TestAuthzHelpers_authClaimsFromToolRequest_Good_ContextClaims(t *testing.T) { + // nil request + claims present in context → returns them, in-transport. + want := &authClaims{Workspace: "core/mcp"} + ctx := withAuthClaims(context.Background(), want) + + claims, inTransport, err := authClaimsFromToolRequest(ctx, nil, "an-api-token") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !inTransport { + t.Fatal("expected inTransport true when claims supplied via context") + } + if claims != want { + t.Fatalf("claims = %v, want %v", claims, want) + } +} + +func TestAuthzHelpers_authClaimsFromToolRequest_Ugly_NoClaimsWithToken(t *testing.T) { + // API token set, nil request, no context claims → not in-transport, no error + // (in-process direct call path). + claims, inTransport, err := authClaimsFromToolRequest(context.Background(), nil, "an-api-token") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if claims != nil || inTransport { + t.Fatalf("expected (nil, false) for direct in-process call, got (%v, %v)", claims, inTransport) + } +} + +type wsStructInput struct { + Repo string `json:"repo"` + Other string `json:"other"` +} + +type wsPtrInput struct { + Workspace *string `json:"workspace"` +} + +func TestAuthzHelpers_inputWorkspaceFromValue_Good_Struct(t *testing.T) { + if got := inputWorkspaceFromValue(wsStructInput{Repo: "core/mcp"}); got != "core/mcp" { + t.Fatalf("struct repo = %q, want core/mcp", got) + } + // Pointer to struct is dereferenced. + if got := inputWorkspaceFromValue(&wsStructInput{Repo: "core/api"}); got != "core/api" { + t.Fatalf("ptr struct repo = %q, want core/api", got) + } + // Pointer-string field. + ws := "core/desktop" + if got := inputWorkspaceFromValue(wsPtrInput{Workspace: &ws}); got != "core/desktop" { + t.Fatalf("ptr-string workspace = %q, want core/desktop", got) + } +} + +func TestAuthzHelpers_inputWorkspaceFromValue_Good_Map(t *testing.T) { + if got := inputWorkspaceFromValue(map[string]any{"workspace": "core/lthn"}); got != "core/lthn" { + t.Fatalf("map workspace = %q", got) + } + // repository alias. + if got := inputWorkspaceFromValue(map[string]string{"repository": "core/go"}); got != "core/go" { + t.Fatalf("map repository = %q", got) + } +} + +func TestAuthzHelpers_inputWorkspaceFromValue_Ugly_NoMatch(t *testing.T) { + if got := inputWorkspaceFromValue(nil); got != "" { + t.Fatalf("nil input = %q, want empty", got) + } + if got := inputWorkspaceFromValue(wsStructInput{Other: "irrelevant"}); got != "" { + t.Fatalf("struct with no ws field = %q, want empty", got) + } + // A nil pointer-string field must not panic and yields empty. + if got := inputWorkspaceFromValue(wsPtrInput{Workspace: nil}); got != "" { + t.Fatalf("nil ptr workspace = %q, want empty", got) + } + // Non-struct, non-map kinds yield empty. + if got := inputWorkspaceFromValue(42); got != "" { + t.Fatalf("int input = %q, want empty", got) + } +} diff --git a/go/pkg/mcp/transformer_helpers_test.go b/go/pkg/mcp/transformer_helpers_test.go new file mode 100644 index 0000000..30869fc --- /dev/null +++ b/go/pkg/mcp/transformer_helpers_test.go @@ -0,0 +1,145 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package mcp + +import ( + "testing" + + "github.com/goccy/go-json" +) + +func TestTransformerHelpers_mapFromAny_Good_AllShapes(t *testing.T) { + // nil → empty map. + if got := mapFromAny(nil); len(got) != 0 { + t.Fatalf("mapFromAny(nil) = %v, want empty", got) + } + + // map[string]any passes through unchanged. + in := map[string]any{"a": 1.0} + if got := mapFromAny(in); got["a"] != 1.0 { + t.Fatalf("mapFromAny(map) lost data: %v", got) + } + + // JSON RawMessage decodes to the object. + raw := json.RawMessage(`{"k":"v"}`) + if got := mapFromAny(raw); got["k"] != "v" { + t.Fatalf("mapFromAny(RawMessage) = %v", got) + } + + // String decodes JSON. + if got := mapFromAny(`{"n":2}`); got["n"] != float64(2) { + t.Fatalf("mapFromAny(string json) = %v", got) + } + + // Arbitrary struct is marshalled then re-parsed. + type payload struct { + Field string `json:"field"` + } + if got := mapFromAny(payload{Field: "x"}); got["field"] != "x" { + t.Fatalf("mapFromAny(struct) = %v", got) + } +} + +func TestTransformerHelpers_mapFromAny_Ugly_NonJSONString(t *testing.T) { + // A non-JSON string is preserved under _raw rather than dropped. + got := mapFromAny("not json at all") + if got["_raw"] != "not json at all" { + t.Fatalf("expected _raw fallback, got %v", got) + } +} + +func TestTransformerHelpers_parseArgumentString_Good_Bad(t *testing.T) { + if got := parseArgumentString(""); len(got) != 0 { + t.Fatalf("empty string should yield empty map, got %v", got) + } + if got := parseArgumentString(`{"a":1}`); got["a"] != float64(1) { + t.Fatalf("parseArgumentString json = %v", got) + } + if got := parseArgumentString("garbage"); got["_raw"] != "garbage" { + t.Fatalf("expected _raw fallback, got %v", got) + } +} + +func TestTransformerHelpers_indexByte_Good_Bad(t *testing.T) { + if i := indexByte("hello", 'l'); i != 2 { + t.Fatalf("indexByte = %d, want 2", i) + } + if i := indexByte("hello", 'z'); i != -1 { + t.Fatalf("indexByte miss = %d, want -1", i) + } + if i := indexByte("", 'a'); i != -1 { + t.Fatalf("indexByte empty = %d, want -1", i) + } +} + +func TestTransformerHelpers_indexAny_Good_Bad(t *testing.T) { + if i := indexAny("abcdef", "xd"); i != 3 { + t.Fatalf("indexAny = %d, want 3 (d)", i) + } + if i := indexAny("abc", "xyz"); i != -1 { + t.Fatalf("indexAny miss = %d, want -1", i) + } +} + +func TestTransformerHelpers_trimBytes_Good(t *testing.T) { + if got := string(trimBytes([]byte(" spaced "))); got != "spaced" { + t.Fatalf("trimBytes = %q", got) + } +} + +func TestTransformerHelpers_extractTextFromAny_Good_Variants(t *testing.T) { + if got := extractTextFromAny(nil); got != nil { + t.Fatalf("nil should yield nil, got %v", got) + } + if got := extractTextFromAny("hi"); len(got) != 1 || got[0] != "hi" { + t.Fatalf("string = %v", got) + } + if got := extractTextFromAny(""); got != nil { + t.Fatalf("empty string should yield nil, got %v", got) + } + if got := extractTextFromAny([]byte("bytes")); len(got) != 1 || got[0] != "bytes" { + t.Fatalf("[]byte = %v", got) + } + // map with a "text" key. + if got := extractTextFromAny(map[string]any{"text": "deep"}); len(got) != 1 || got[0] != "deep" { + t.Fatalf("map text = %v", got) + } + // nested content slice. + nested := map[string]any{"content": []any{"one", "two"}} + if got := extractTextFromAny(nested); len(got) != 2 { + t.Fatalf("nested content = %v", got) + } +} + +func TestTransformerHelpers_normaliseAnthropicTools_Good(t *testing.T) { + out := normaliseAnthropicTools([]anthropicTool{ + {Name: "read_file", Description: "reads", InputSchema: map[string]any{"type": "object"}}, + }) + if len(out) != 1 { + t.Fatalf("expected 1 normalised tool, got %d", len(out)) + } + if out[0]["name"] != "read_file" || out[0]["description"] != "reads" { + t.Fatalf("unexpected normalisation: %v", out[0]) + } + if _, ok := out[0]["input_schema"]; !ok { + t.Fatal("expected input_schema key") + } +} + +func TestTransformerHelpers_normaliseOpenAITools_Good_FunctionAndCustom(t *testing.T) { + out := normaliseOpenAITools([]openAITool{ + {Type: "function", Function: openAIFunctionMetadata{Name: "search", Description: "find", Parameters: map[string]any{"type": "object"}}}, + {Type: "custom", Function: openAIFunctionMetadata{Name: "weird"}}, + }) + if len(out) != 2 { + t.Fatalf("expected 2 normalised tools, got %d", len(out)) + } + // Function tool maps to the MCP-native name/description/input_schema shape. + if out[0]["name"] != "search" || out[0]["description"] != "find" { + t.Fatalf("function tool normalisation: %v", out[0]) + } + // Custom (non-function) tool keeps its type+function envelope. + if out[1]["type"] != "custom" { + t.Fatalf("custom tool should preserve type, got %v", out[1]) + } +} From aa553e4ad43a28ec9243e20e2e524e3af1332354 Mon Sep 17 00:00:00 2001 From: Snider Date: Mon, 1 Jun 2026 00:51:21 +0100 Subject: [PATCH 12/19] test(brain): cover provider HTTP handlers and subsystem tool error paths Adds Good/Bad/Ugly coverage for previously-thin brain surfaces: - provider remember/recall/forget/list gin handlers across the three response branches: 503 nil-bridge, 400 malformed-JSON, 500 bridge-not-connected - Subsystem brainForget/brainList over nil-bridge, over-length-org validation, and disconnected-bridge send failure; validateListInput Good/Bad Package coverage 61.6% -> 82.6%. Additive tests only; no production change. Co-Authored-By: Virgil --- go/pkg/mcp/brain/provider_handlers_test.go | 130 +++++++++++++++++++++ go/pkg/mcp/brain/tools_subsystem_test.go | 56 +++++++++ 2 files changed, 186 insertions(+) create mode 100644 go/pkg/mcp/brain/provider_handlers_test.go create mode 100644 go/pkg/mcp/brain/tools_subsystem_test.go diff --git a/go/pkg/mcp/brain/provider_handlers_test.go b/go/pkg/mcp/brain/provider_handlers_test.go new file mode 100644 index 0000000..074030d --- /dev/null +++ b/go/pkg/mcp/brain/provider_handlers_test.go @@ -0,0 +1,130 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package brain + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "dappco.re/go/mcp/pkg/mcp/ide" + "github.com/gin-gonic/gin" +) + +// routerForProvider mounts a BrainProvider's routes under /api/brain. +func routerForProvider(p *BrainProvider) *gin.Engine { + gin.SetMode(gin.TestMode) + router := gin.New() + p.RegisterRoutes(router.Group("/api/brain")) + return router +} + +// disconnectedBridge returns a Bridge with no live connection: Send always +// errors, exercising the bridge_error (500) branch of each handler. +func disconnectedBridge() *ide.Bridge { + return ide.NewBridge(nil, ide.DefaultConfig()) +} + +func doReq(router *gin.Engine, method, path, body string) *httptest.ResponseRecorder { + rr := httptest.NewRecorder() + var req *http.Request + if body == "" { + req = httptest.NewRequest(method, path, nil) + } else { + req = httptest.NewRequest(method, path, strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + } + router.ServeHTTP(rr, req) + return rr +} + +func TestProviderHandlers_remember_Ugly_NilBridge503(t *testing.T) { + router := routerForProvider(NewProvider(nil, nil)) + rr := doReq(router, http.MethodPost, "/api/brain/remember", `{"content":"x","type":"bug"}`) + if rr.Code != http.StatusServiceUnavailable { + t.Fatalf("expected 503 with nil bridge, got %d (%s)", rr.Code, rr.Body.String()) + } +} + +func TestProviderHandlers_remember_Bad_MalformedJSON400(t *testing.T) { + router := routerForProvider(NewProvider(disconnectedBridge(), nil)) + rr := doReq(router, http.MethodPost, "/api/brain/remember", `{not valid json`) + if rr.Code != http.StatusBadRequest { + t.Fatalf("expected 400 for malformed JSON, got %d (%s)", rr.Code, rr.Body.String()) + } +} + +func TestProviderHandlers_remember_Good_BridgeNotConnected500(t *testing.T) { + router := routerForProvider(NewProvider(disconnectedBridge(), nil)) + rr := doReq(router, http.MethodPost, "/api/brain/remember", `{"content":"hello","type":"note","org":"core"}`) + // Valid input parses, then Send fails because the bridge has no connection. + if rr.Code != http.StatusInternalServerError { + t.Fatalf("expected 500 bridge_error, got %d (%s)", rr.Code, rr.Body.String()) + } +} + +func TestProviderHandlers_recall_Ugly_NilBridge503(t *testing.T) { + router := routerForProvider(NewProvider(nil, nil)) + rr := doReq(router, http.MethodPost, "/api/brain/recall", `{"query":"x"}`) + if rr.Code != http.StatusServiceUnavailable { + t.Fatalf("expected 503, got %d", rr.Code) + } +} + +func TestProviderHandlers_recall_Bad_MalformedJSON400(t *testing.T) { + router := routerForProvider(NewProvider(disconnectedBridge(), nil)) + rr := doReq(router, http.MethodPost, "/api/brain/recall", `[`) + if rr.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", rr.Code) + } +} + +func TestProviderHandlers_recall_Good_BridgeNotConnected500(t *testing.T) { + router := routerForProvider(NewProvider(disconnectedBridge(), nil)) + rr := doReq(router, http.MethodPost, "/api/brain/recall", `{"query":"what do we know","top_k":5}`) + if rr.Code != http.StatusInternalServerError { + t.Fatalf("expected 500, got %d (%s)", rr.Code, rr.Body.String()) + } +} + +func TestProviderHandlers_forget_Ugly_NilBridge503(t *testing.T) { + router := routerForProvider(NewProvider(nil, nil)) + rr := doReq(router, http.MethodPost, "/api/brain/forget", `{"id":"m1"}`) + if rr.Code != http.StatusServiceUnavailable { + t.Fatalf("expected 503, got %d", rr.Code) + } +} + +func TestProviderHandlers_forget_Bad_MalformedJSON400(t *testing.T) { + router := routerForProvider(NewProvider(disconnectedBridge(), nil)) + rr := doReq(router, http.MethodPost, "/api/brain/forget", `{`) + if rr.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", rr.Code) + } +} + +func TestProviderHandlers_forget_Good_BridgeNotConnected500(t *testing.T) { + router := routerForProvider(NewProvider(disconnectedBridge(), nil)) + rr := doReq(router, http.MethodPost, "/api/brain/forget", `{"id":"mem-123","reason":"stale"}`) + if rr.Code != http.StatusInternalServerError { + t.Fatalf("expected 500, got %d (%s)", rr.Code, rr.Body.String()) + } +} + +func TestProviderHandlers_list_Ugly_NilBridge503(t *testing.T) { + router := routerForProvider(NewProvider(nil, nil)) + rr := doReq(router, http.MethodGet, "/api/brain/list?project=core/mcp", "") + if rr.Code != http.StatusServiceUnavailable { + t.Fatalf("expected 503, got %d", rr.Code) + } +} + +func TestProviderHandlers_list_Good_BridgeNotConnected500(t *testing.T) { + router := routerForProvider(NewProvider(disconnectedBridge(), nil)) + rr := doReq(router, http.MethodGet, "/api/brain/list?project=core/mcp&org=core&type=note&limit=10", "") + // GET handler reads query params then Send fails on the dead bridge. + if rr.Code != http.StatusInternalServerError { + t.Fatalf("expected 500, got %d (%s)", rr.Code, rr.Body.String()) + } +} diff --git a/go/pkg/mcp/brain/tools_subsystem_test.go b/go/pkg/mcp/brain/tools_subsystem_test.go new file mode 100644 index 0000000..b61d98c --- /dev/null +++ b/go/pkg/mcp/brain/tools_subsystem_test.go @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package brain + +import ( + "context" + "strings" + "testing" +) + +func TestToolsSubsystem_brainForget_Bad_NilBridge(t *testing.T) { + sub := &Subsystem{} + if _, _, err := sub.brainForget(context.Background(), nil, ForgetInput{ID: "m1"}); err == nil { + t.Fatal("expected errBridgeNotAvailable with nil bridge") + } +} + +func TestToolsSubsystem_brainForget_Ugly_BridgeNotConnected(t *testing.T) { + sub := New(disconnectedBridge()) + if _, _, err := sub.brainForget(context.Background(), nil, ForgetInput{ID: "mem-9", Reason: "stale"}); err == nil { + t.Fatal("expected send failure with disconnected bridge") + } +} + +func TestToolsSubsystem_brainList_Bad_NilBridge(t *testing.T) { + sub := &Subsystem{} + if _, _, err := sub.brainList(context.Background(), nil, ListInput{Project: "core/mcp"}); err == nil { + t.Fatal("expected errBridgeNotAvailable with nil bridge") + } +} + +func TestToolsSubsystem_brainList_Bad_OrgTooLong(t *testing.T) { + sub := New(disconnectedBridge()) + // Validation runs before the bridge send, so an over-long org fails fast. + longOrg := strings.Repeat("a", 200) + if _, _, err := sub.brainList(context.Background(), nil, ListInput{Org: longOrg}); err == nil { + t.Fatal("expected validation error for over-length org") + } +} + +func TestToolsSubsystem_brainList_Ugly_BridgeNotConnected(t *testing.T) { + sub := New(disconnectedBridge()) + // Valid input (default limit applied), then Send fails on the dead bridge. + if _, _, err := sub.brainList(context.Background(), nil, ListInput{Project: "core/mcp"}); err == nil { + t.Fatal("expected send failure with disconnected bridge") + } +} + +func TestToolsSubsystem_validateListInput_Good_Bad(t *testing.T) { + if err := validateListInput(ListInput{Org: "core"}); err != nil { + t.Fatalf("expected valid short org, got %v", err) + } + if err := validateListInput(ListInput{Org: strings.Repeat("x", 200)}); err == nil { + t.Fatal("expected error for over-length org") + } +} From 79bae45d0aefc04e6ed20ee044f8f32ecb3f121a Mon Sep 17 00:00:00 2001 From: Snider Date: Mon, 1 Jun 2026 00:52:58 +0100 Subject: [PATCH 13/19] test(ide): cover bridge data parsers and build-log state helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Good/Bad/Ugly coverage for previously-0%/low ide surfaces: - buildInfosFromData, buildLogsFromData ([]any/[]string/output variants), buildLinesFromData, sessionsFromData, sessionFromData, chatHistoryFromData (sessionId/session_id fallbacks), chatMessageFromData, stringFromAny — all exercising malformed/wrong-shape inputs - listBuilds repo filter + limit + most-recent ordering, appendBuildLog + buildLogTail full/bounded/oversize/missing Package coverage 72.2% -> 89.5%. Additive tests only; no production change. Co-Authored-By: Virgil --- go/pkg/mcp/ide/ide_data_test.go | 217 ++++++++++++++++++++++++++++++++ 1 file changed, 217 insertions(+) create mode 100644 go/pkg/mcp/ide/ide_data_test.go diff --git a/go/pkg/mcp/ide/ide_data_test.go b/go/pkg/mcp/ide/ide_data_test.go new file mode 100644 index 0000000..60898ee --- /dev/null +++ b/go/pkg/mcp/ide/ide_data_test.go @@ -0,0 +1,217 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package ide + +import ( + "testing" +) + +func TestIdeData_buildInfosFromData_Good_Bad(t *testing.T) { + data := map[string]any{ + "builds": []any{ + map[string]any{"id": "b1", "repo": "core/mcp", "status": "running"}, + map[string]any{"id": "b2", "repo": "core/api", "status": "passed"}, + map[string]any{"no_id": true}, // skipped: no id + }, + } + got := buildInfosFromData(data) + if len(got) != 2 { + t.Fatalf("expected 2 builds, got %d", len(got)) + } + if got[0].ID != "b1" || got[0].Repo != "core/mcp" { + t.Fatalf("unexpected first build: %+v", got[0]) + } + + // Wrong shape → empty slice (non-nil). + if out := buildInfosFromData("not a map"); len(out) != 0 { + t.Fatalf("expected empty for non-map, got %d", len(out)) + } + if out := buildInfosFromData(map[string]any{"builds": "wrong"}); len(out) != 0 { + t.Fatalf("expected empty for non-slice builds, got %d", len(out)) + } +} + +func TestIdeData_buildLogsFromData_Good_Variants(t *testing.T) { + // []any lines. + id, lines := buildLogsFromData(map[string]any{ + "buildId": "b1", + "lines": []any{"line one", "line two"}, + }) + if id != "b1" || len(lines) != 2 { + t.Fatalf("[]any lines: id=%q lines=%v", id, lines) + } + + // []string lines + "id" fallback key. + id, lines = buildLogsFromData(map[string]any{ + "id": "b2", + "lines": []string{"a", "b", "c"}, + }) + if id != "b2" || len(lines) != 3 { + t.Fatalf("[]string lines: id=%q lines=%v", id, lines) + } + + // "output" fallback. + id, lines = buildLogsFromData(map[string]any{ + "buildId": "b3", + "output": "single blob", + }) + if id != "b3" || len(lines) != 1 || lines[0] != "single blob" { + t.Fatalf("output fallback: id=%q lines=%v", id, lines) + } + + // buildLinesFromData is a thin wrapper. + if l := buildLinesFromData(map[string]any{"lines": []any{"x"}}); len(l) != 1 { + t.Fatalf("buildLinesFromData = %v", l) + } +} + +func TestIdeData_buildLogsFromData_Ugly_WrongShape(t *testing.T) { + id, lines := buildLogsFromData(42) + if id != "" || len(lines) != 0 { + t.Fatalf("expected empty for non-map, got id=%q lines=%v", id, lines) + } +} + +func TestIdeData_sessionsFromData_Good_Bad(t *testing.T) { + got := sessionsFromData(map[string]any{ + "sessions": []any{ + map[string]any{"id": "s1", "name": "alpha", "status": "active"}, + map[string]any{"id": "s2"}, // status defaults to "unknown" + map[string]any{"name": "no"}, // skipped: no id + }, + }) + if len(got) != 2 { + t.Fatalf("expected 2 sessions, got %d", len(got)) + } + if got[1].Status != "unknown" { + t.Fatalf("expected default status unknown, got %q", got[1].Status) + } + + if out := sessionsFromData("nope"); len(out) != 0 { + t.Fatalf("expected empty for non-map, got %d", len(out)) + } +} + +func TestIdeData_sessionFromData_Bad_NoID(t *testing.T) { + if _, ok := sessionFromData(map[string]any{"name": "x"}); ok { + t.Fatal("expected ok=false when id missing") + } + if _, ok := sessionFromData("not a map"); ok { + t.Fatal("expected ok=false for non-map") + } +} + +func TestIdeData_chatHistoryFromData_Good_Bad(t *testing.T) { + sessionID, msgs := chatHistoryFromData(map[string]any{ + "sessionId": "s1", + "messages": []any{ + map[string]any{"role": "user", "content": "hi"}, + map[string]any{"role": "assistant", "content": "hello"}, + map[string]any{}, // skipped: empty role+content + }, + }) + if sessionID != "s1" || len(msgs) != 2 { + t.Fatalf("history: sessionID=%q msgs=%d", sessionID, len(msgs)) + } + + // session_id fallback key + no messages. + sessionID, msgs = chatHistoryFromData(map[string]any{"session_id": "s9"}) + if sessionID != "s9" || len(msgs) != 0 { + t.Fatalf("fallback: sessionID=%q msgs=%d", sessionID, len(msgs)) + } + + // Wrong shape. + if sid, m := chatHistoryFromData(nil); sid != "" || len(m) != 0 { + t.Fatalf("expected empty for nil, got sid=%q m=%d", sid, len(m)) + } +} + +func TestIdeData_chatMessageFromData_Bad_Empty(t *testing.T) { + if _, ok := chatMessageFromData(map[string]any{}); ok { + t.Fatal("expected ok=false for empty role+content") + } + if _, ok := chatMessageFromData("nope"); ok { + t.Fatal("expected ok=false for non-map") + } + // content-only is valid. + if msg, ok := chatMessageFromData(map[string]any{"content": "just content"}); !ok || msg.Content != "just content" { + t.Fatalf("expected content-only message, got %+v ok=%v", msg, ok) + } +} + +func TestIdeData_stringFromAny_Good(t *testing.T) { + if stringFromAny("hi") != "hi" { + t.Fatal("string passthrough failed") + } + if stringFromAny(123) != "" { + t.Fatal("non-string should yield empty") + } + if stringFromAny(nil) != "" { + t.Fatal("nil should yield empty") + } +} + +func TestIdeState_listBuilds_Good_FilterAndLimit(t *testing.T) { + s := &Subsystem{} + s.addBuild(BuildInfo{ID: "b1", Repo: "core/mcp", Status: "passed"}) + s.addBuild(BuildInfo{ID: "b2", Repo: "core/api", Status: "running"}) + s.addBuild(BuildInfo{ID: "b3", Repo: "core/mcp", Status: "failed"}) + + // No filter: all three, most-recent-first. + all := s.listBuilds("", 0) + if len(all) != 3 { + t.Fatalf("expected 3 builds, got %d", len(all)) + } + if all[0].ID != "b3" { + t.Fatalf("expected most-recent first (b3), got %q", all[0].ID) + } + + // Repo filter. + mcp := s.listBuilds("core/mcp", 0) + if len(mcp) != 2 { + t.Fatalf("expected 2 core/mcp builds, got %d", len(mcp)) + } + + // Limit. + one := s.listBuilds("", 1) + if len(one) != 1 { + t.Fatalf("expected 1 build under limit, got %d", len(one)) + } +} + +func TestIdeState_listBuilds_Ugly_Empty(t *testing.T) { + if out := (&Subsystem{}).listBuilds("", 0); len(out) != 0 { + t.Fatalf("expected empty for no builds, got %d", len(out)) + } +} + +func TestIdeState_appendBuildLogAndTail_Good(t *testing.T) { + s := &Subsystem{} + for _, line := range []string{"l1", "l2", "l3", "l4"} { + s.appendBuildLog("b1", line) + } + + // Full tail. + full := s.buildLogTail("b1", 0) + if len(full) != 4 { + t.Fatalf("expected 4 lines, got %d", len(full)) + } + + // Bounded tail. + tail := s.buildLogTail("b1", 2) + if len(tail) != 2 || tail[0] != "l3" || tail[1] != "l4" { + t.Fatalf("unexpected tail: %v", tail) + } + + // Tail larger than available collapses to all. + big := s.buildLogTail("b1", 100) + if len(big) != 4 { + t.Fatalf("expected 4 lines for oversize tail, got %d", len(big)) + } +} + +func TestIdeState_buildLogTail_Ugly_Missing(t *testing.T) { + if out := (&Subsystem{}).buildLogTail("absent", 5); len(out) != 0 { + t.Fatalf("expected empty for missing build, got %d", len(out)) + } +} From 1cfce6fbca3c499ae79f31529106fdc13480b09a Mon Sep 17 00:00:00 2001 From: Snider Date: Mon, 1 Jun 2026 00:54:09 +0100 Subject: [PATCH 14/19] test(agentic): cover prompt-template selection and atomic write paths Adds Good/Bad/Ugly coverage for: - writePromptTemplate across conventions/security/coding templates + the default unknown-template fallback (verifies PROMPT.md content per branch) - writeAtomic parent-dir creation, overwrite, and the file-as-directory collision error path Additive tests only; no production change. Co-Authored-By: Virgil --- go/pkg/mcp/agentic/prep_template_test.go | 92 ++++++++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 go/pkg/mcp/agentic/prep_template_test.go diff --git a/go/pkg/mcp/agentic/prep_template_test.go b/go/pkg/mcp/agentic/prep_template_test.go new file mode 100644 index 0000000..2a3464d --- /dev/null +++ b/go/pkg/mcp/agentic/prep_template_test.go @@ -0,0 +1,92 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package agentic + +import ( + "testing" + + core "dappco.re/go" +) + +func TestPrep_writePromptTemplate_Good_KnownTemplates(t *testing.T) { + sub := newPlanSub(t) + cases := map[string]string{ + "conventions": "project conventions", + "security": "security issues", + "coding": "PERSONA.md", + } + for tmpl, marker := range cases { + wsDir := core.Path(sub.codePath, "ws-"+tmpl) + sub.writePromptTemplate(tmpl, wsDir) + + got, err := coreio.Local.Read(core.Path(wsDir, "src", "PROMPT.md")) + if err != nil { + t.Fatalf("read PROMPT.md for %q: %v", tmpl, err) + } + if !core.Contains(got, marker) { + t.Fatalf("template %q PROMPT.md missing %q:\n%s", tmpl, marker, got) + } + } +} + +func TestPrep_writePromptTemplate_Ugly_UnknownTemplateFallback(t *testing.T) { + sub := newPlanSub(t) + wsDir := core.Path(sub.codePath, "ws-unknown") + sub.writePromptTemplate("does-not-exist", wsDir) + + got, err := coreio.Local.Read(core.Path(wsDir, "src", "PROMPT.md")) + if err != nil { + t.Fatalf("read PROMPT.md: %v", err) + } + if !core.Contains(got, "Read TODO.md") { + t.Fatalf("expected default fallback prompt, got:\n%s", got) + } +} + +func TestPrep_writeAtomic_Good_CreatesParentDirs(t *testing.T) { + root := t.TempDir() + path := core.Path(root, "deep", "nested", "file.txt") + + if err := writeAtomic(path, "payload"); err != nil { + t.Fatalf("writeAtomic: %v", err) + } + got, err := coreio.Local.Read(path) + if err != nil { + t.Fatalf("read back: %v", err) + } + if got != "payload" { + t.Fatalf("expected payload, got %q", got) + } +} + +func TestPrep_writeAtomic_Good_Overwrite(t *testing.T) { + root := t.TempDir() + path := core.Path(root, "file.txt") + + if err := writeAtomic(path, "first"); err != nil { + t.Fatalf("first write: %v", err) + } + if err := writeAtomic(path, "second"); err != nil { + t.Fatalf("overwrite: %v", err) + } + got, err := coreio.Local.Read(path) + if err != nil { + t.Fatalf("read back: %v", err) + } + if got != "second" { + t.Fatalf("expected overwrite to second, got %q", got) + } +} + +func TestPrep_writeAtomic_Ugly_DirCollision(t *testing.T) { + root := t.TempDir() + // A file exists where writeAtomic wants a parent directory. + blocker := core.Path(root, "blocker") + if err := writeAtomic(blocker, "x"); err != nil { + t.Fatalf("seed blocker: %v", err) + } + // Now try to write under the blocker as if it were a directory. + if err := writeAtomic(core.Path(blocker, "child.txt"), "y"); err == nil { + t.Fatal("expected error writing under a file-as-directory") + } +} From 0a291488b5d9c3f5e95e4962fa5a1847b6f42011 Mon Sep 17 00:00:00 2001 From: Snider Date: Mon, 1 Jun 2026 00:55:27 +0100 Subject: [PATCH 15/19] test(mcp): real-invocation coverage for service factory, startup, notify helpers Replaces type-identity-only coverage with behavioural tests: - NewService factory builds a Service with a wired ServiceRuntime + Core - OnStartup registers the mcp and serve commands on the Core; nil-runtime OnStartup is a safe no-op - debugNotify nil-receiver/logger-less safety + with-logger path, coreNotifyError message round-trip, snapshotSessions nil-server guard Additive tests only; no production change. Co-Authored-By: Virgil --- go/pkg/mcp/service_behaviour_test.go | 95 ++++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 go/pkg/mcp/service_behaviour_test.go diff --git a/go/pkg/mcp/service_behaviour_test.go b/go/pkg/mcp/service_behaviour_test.go new file mode 100644 index 0000000..f21ab3c --- /dev/null +++ b/go/pkg/mcp/service_behaviour_test.go @@ -0,0 +1,95 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package mcp + +import ( + "context" + "testing" + + core "dappco.re/go" +) + +func TestServiceBehaviour_NewService_Good_BuildsService(t *testing.T) { + c := core.New() + factory := NewService(Options{WorkspaceRoot: t.TempDir()}) + + result := factory(c) + if !result.OK { + t.Fatalf("NewService factory failed: %v", result.Value) + } + svc, ok := result.Value.(*Service) + if !ok { + t.Fatalf("expected *Service, got %T", result.Value) + } + if svc.ServiceRuntime == nil { + t.Fatal("expected ServiceRuntime to be wired") + } + if svc.Core() != c { + t.Fatal("expected Core to be the one passed to the factory") + } +} + +func TestServiceBehaviour_OnStartup_Good_RegistersCommands(t *testing.T) { + c := core.New() + result := NewService(Options{WorkspaceRoot: t.TempDir()})(c) + if !result.OK { + t.Fatalf("NewService factory failed: %v", result.Value) + } + svc := result.Value.(*Service) + + if r := svc.OnStartup(context.Background()); !r.OK { + t.Fatalf("OnStartup failed: %v", r.Value) + } + // The mcp and serve commands must now be present on the Core. + cmds := map[string]bool{} + for _, name := range c.Commands() { + cmds[name] = true + } + if !cmds["mcp"] { + t.Fatal("expected mcp command to be registered") + } + if !cmds["serve"] { + t.Fatal("expected serve command to be registered") + } +} + +func TestServiceBehaviour_OnStartup_Ugly_NilRuntimeIsNoOp(t *testing.T) { + // A Service without a ServiceRuntime must not panic and returns Ok(nil). + svc, err := New(Options{WorkspaceRoot: t.TempDir()}) + if err != nil { + t.Fatalf("New: %v", err) + } + if r := svc.OnStartup(context.Background()); !r.OK { + t.Fatalf("expected Ok for nil-runtime OnStartup, got %v", r.Value) + } +} + +func TestNotifyBehaviour_debugNotify_Ugly_NilSafe(t *testing.T) { + // Must not panic on a nil receiver or a logger-less service. + var nilSvc *Service + nilSvc.debugNotify("ignored") + + svc := &Service{} + svc.debugNotify("also ignored", "k", "v") +} + +func TestNotifyBehaviour_debugNotify_Good_WithLogger(t *testing.T) { + svc := newTmpService(t) // has a logger + svc.debugNotify("debug line", "tool", "metrics") +} + +func TestNotifyBehaviour_coreNotifyError_Good(t *testing.T) { + err := coreNotifyError("boom") + if err == nil { + t.Fatal("expected non-nil error") + } + if err.Error() != "boom" { + t.Fatalf("error message = %q, want boom", err.Error()) + } +} + +func TestNotifyBehaviour_snapshotSessions_Ugly_NilServer(t *testing.T) { + if got := snapshotSessions(nil); got != nil { + t.Fatalf("expected nil for nil server, got %v", got) + } +} From 152314465f10fadc8dca0085760160fc5c6020c6 Mon Sep 17 00:00:00 2001 From: Snider Date: Mon, 1 Jun 2026 10:03:35 +0100 Subject: [PATCH 16/19] fix(ide): NewBridge applies WithDefaults so reconnect backoff is never zero MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A raw Config{} (callers building it inline, e.g. the agent hub) left ReconnectInterval at 0, and connectLoop's backoff min(delay*2, max) stayed pinned at 0 → a tight reconnect flood instead of 2s→30s. Apply WithDefaults in NewBridge so every bridge has sane intervals regardless of caller. Co-Authored-By: Virgil --- go/pkg/mcp/ide/bridge.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/go/pkg/mcp/ide/bridge.go b/go/pkg/mcp/ide/bridge.go index 1f699cb..0f003cc 100644 --- a/go/pkg/mcp/ide/bridge.go +++ b/go/pkg/mcp/ide/bridge.go @@ -48,7 +48,11 @@ type Bridge struct { // // bridge := NewBridge(hub, cfg) func NewBridge(hub *ws.Hub, cfg Config) *Bridge { - return &Bridge{cfg: cfg, hub: hub} + // WithDefaults guarantees non-zero reconnect intervals. Without it a raw + // Config{} leaves ReconnectInterval at 0, and connectLoop's backoff + // (min(delay*2, max)) stays pinned at 0 → a tight reconnect flood. Callers + // that build the Config inline (e.g. the agent hub) rely on this. + return &Bridge{cfg: cfg.WithDefaults(), hub: hub} } // SetObserver registers a callback for inbound bridge messages. From 53c800731dcddf982d9f84207f537a9e365b194e Mon Sep 17 00:00:00 2001 From: Snider Date: Sat, 13 Jun 2026 10:01:43 +0100 Subject: [PATCH 17/19] =?UTF-8?q?chore(deps):=20bump=20dappco.re/go=20v0.1?= =?UTF-8?q?0.3=20=E2=86=92=20v0.10.4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Advance external/go workspace submodule to v0.10.4 so dev (GOWORK on) and standalone (GOWORK=off) builds resolve the same core/go. Co-Authored-By: Virgil --- external/go | 2 +- go/go.mod | 2 +- go/go.sum | 6 ++---- 3 files changed, 4 insertions(+), 6 deletions(-) diff --git a/external/go b/external/go index f7a84db..7c95f96 160000 --- a/external/go +++ b/external/go @@ -1 +1 @@ -Subproject commit f7a84db6ce08722dc3d42ad72ed9094621fca992 +Subproject commit 7c95f964f84bd52c728c67c9cce49f1b9bf5e066 diff --git a/go/go.mod b/go/go.mod index 027b311..34cd604 100644 --- a/go/go.mod +++ b/go/go.mod @@ -33,7 +33,7 @@ require ( ) require ( - dappco.re/go v0.10.3 + dappco.re/go v0.10.4 github.com/bytedance/gopkg v0.1.4 // indirect github.com/bytedance/sonic v1.15.0 // indirect github.com/bytedance/sonic/loader v0.5.0 // indirect diff --git a/go/go.sum b/go/go.sum index 69b34c2..ee6ad83 100644 --- a/go/go.sum +++ b/go/go.sum @@ -1,7 +1,5 @@ -dappco.re/go v0.9.0 h1:4ruZRNqKDDva8o6g65tYggjGVe42E6/lMZfVKXtr3p0= -dappco.re/go v0.9.0/go.mod h1:xapr7fLK4/9Pu2iSCr4qZuIuatmtx1j56zS/oPDbGyQ= -dappco.re/go v0.10.3 h1:aViRNxdg2jG84P6RsiD+aSta+GcFJwGXMNQPjFPbJ9g= -dappco.re/go v0.10.3/go.mod h1:xapr7fLK4/9Pu2iSCr4qZuIuatmtx1j56zS/oPDbGyQ= +dappco.re/go v0.10.4 h1:vir5AK8AkHbTxhPUT0et6Tc0P8i/i+gLInM0LRLt1EU= +dappco.re/go v0.10.4/go.mod h1:xapr7fLK4/9Pu2iSCr4qZuIuatmtx1j56zS/oPDbGyQ= dappco.re/go/io v0.9.0 h1:TyHUuUJdZ73CXQlBpqx47SNyFFzgwA5OPSKu4Twb2f0= dappco.re/go/io v0.9.0/go.mod h1:K5jWSLMdk0X9HqJ6b1I+8tKqcNpNWgpcUZi/fGm28Q8= dappco.re/go/log v0.9.0 h1:9+OiBUDyUNvqZZ++XemcjJPCgypr+Yf/1e5OP3X2nrk= From ee6cf1d2d16fde11d730f2ed2313f584d61fddfe Mon Sep 17 00:00:00 2001 From: Snider Date: Sat, 27 Jun 2026 14:03:52 +0100 Subject: [PATCH 18/19] chore: remove GEMINI/TODO/ROADMAP cruft from repo root Co-Authored-By: Virgil --- php/GEMINI.md | 122 -------------------------------------------------- 1 file changed, 122 deletions(-) delete mode 100644 php/GEMINI.md diff --git a/php/GEMINI.md b/php/GEMINI.md deleted file mode 100644 index 103179c..0000000 --- a/php/GEMINI.md +++ /dev/null @@ -1,122 +0,0 @@ -# Core PHP Framework - AI Assistant Context - -> For Gemini Code Assist, Jules, and other Google AI tools. - -## Project Type - -Laravel 12 application using Core PHP Framework - a modular monolith with event-driven architecture. - -## Directory Structure - -``` -app/ -├── Mod/ # Feature modules (your code) -│ └── {Name}/ -│ ├── Boot.php # Event listeners -│ ├── Models/ -│ ├── Routes/ -│ ├── Views/ -│ └── Livewire/ -├── Core/ # Local framework overrides (EUPL-1.2) -└── Providers/ - -config/core.php # Framework configuration -``` - -## Module Pattern - -Every module has a `Boot.php` with static `$listens` array: - -```php - 'onWebRoutes', - ApiRoutesRegistering::class => 'onApiRoutes', - AdminPanelBooting::class => 'onAdminPanel', - ]; - - public function onWebRoutes(WebRoutesRegistering $event): void - { - $event->routes(fn() => require __DIR__.'/Routes/web.php'); - $event->views('shop', __DIR__.'/Views'); - } - - public function onApiRoutes(ApiRoutesRegistering $event): void - { - $event->routes(fn() => require __DIR__.'/Routes/api.php'); - } - - public function onAdminPanel(AdminPanelBooting $event): void - { - $event->navigation('Shop', 'shop.admin.index', 'shopping-cart'); - } -} -``` - -## Code Style Requirements - -### Language: UK English -- colour (not color) -- organisation (not organization) -- centre (not center) -- behaviour (not behavior) -- licence (noun), license (verb) - -### PHP Standards -- `declare(strict_types=1);` in all files -- Full type hints (parameters + return types) -- PSR-12 formatting (use Laravel Pint) -- Pest for testing - -### Naming Conventions -| Type | Convention | Example | -|------|------------|---------| -| Model | Singular PascalCase | `Product` | -| Table | Plural snake_case | `products` | -| Controller | `{Model}Controller` | `ProductController` | -| Livewire Page | `{Feature}Page` | `ProductListPage` | -| Livewire Modal | `{Feature}Modal` | `EditProductModal` | - -## UI Stack - -- **Livewire 3** - Server-side reactivity -- **Flux Pro** - UI component library (NOT vanilla Alpine) -- **Tailwind CSS** - Utility-first styling -- **Font Awesome Pro** - Icons (NOT Heroicons) - -## Common Commands - -```bash -php artisan make:mod Blog --all # Create module with all features -php artisan serve # Development server -vendor/bin/pint --dirty # Format changed files -vendor/bin/pest # Run tests -``` - -## Packages - -| Package | Namespace | Purpose | -|---------|-----------|---------| -| host-uk/core | `Core\` | Framework core | -| host-uk/core-admin | `Core\Admin\` | Admin panel | -| host-uk/core-api | `Core\Api\` | REST API | -| host-uk/core-mcp | `Core\Mcp\` | AI agent tools | - -## Avoid - -- American English spellings -- Heroicons (use Font Awesome) -- Vanilla Alpine components (use Flux) -- Over-engineering / premature abstraction -- PHPUnit syntax (use Pest) From a43e626a03cbe54279dbffbed714d97d1a8a169a Mon Sep 17 00:00:00 2001 From: Snider Date: Sat, 27 Jun 2026 14:16:49 +0100 Subject: [PATCH 19/19] chore: remove stale EXCEPTIONS.md from repo root Co-Authored-By: Virgil --- EXCEPTIONS.md | 17 ----------------- 1 file changed, 17 deletions(-) delete mode 100644 EXCEPTIONS.md diff --git a/EXCEPTIONS.md b/EXCEPTIONS.md deleted file mode 100644 index 71fad0c..0000000 --- a/EXCEPTIONS.md +++ /dev/null @@ -1,17 +0,0 @@ -# Exceptions - -Items from the Codex review that cannot be fixed, with reasons. - -## 6. Compile-time interface assertions in subsystem packages - -**Files:** `brain/brain.go`, `brain/direct.go`, `agentic/prep.go`, `ide/ide.go` - -**Finding:** Add `var _ Subsystem = (*T)(nil)` compile-time assertions. - -**Reason:** The `Subsystem` interface is defined in the parent `mcp` package. Subsystem packages (`brain`, `agentic`, `ide`) cannot import `mcp` because `mcp` already imports them via `Options.Subsystems` — this would create a circular import. The interface conformance is enforced at runtime when `RegisterTools` is called during `mcp.New()`. - -## 7. Compile-time Notifier assertion on Service - -**Finding:** Add `var _ Notifier = (*Service)(nil)`. - -**Resolution:** Fixed — assertion added to `pkg/mcp/subsystem.go` (where the `Notifier` interface is defined). The TODO originally claimed this was already done in commit `907d62a`, but it was not present in the codebase.