diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index a276b9452..4446870e3 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -8,7 +8,7 @@ "plugins": [ { "name": "nemo-relay-plugin", - "description": "Forward Claude Code lifecycle hooks to a local NeMo Relay sidecar.", + "description": "Run the shared native Relay gateway and capture Claude Code lifecycle events.", "source": "./integrations/coding-agents/claude-code", "category": "development" } diff --git a/.config/nextest.toml b/.config/nextest.toml index caf149a06..8d7e04f1c 100644 --- a/.config/nextest.toml +++ b/.config/nextest.toml @@ -3,3 +3,11 @@ [profile.ci] junit = { path = "rust_junit_report.xml" } + +[[profile.ci.overrides]] +filter = 'test(windows_supervision_assigns_before_a_wrapper_can_spawn_a_descendant)' +slow-timeout = { period = "20s", terminate-after = 2 } + +[[profile.ci.overrides]] +filter = 'test(cli_hook_recovery_consumes_the_same_restart_as_an_overlapping_mcp)' +slow-timeout = { period = "20s", terminate-after = 2 } diff --git a/ATTRIBUTIONS-Rust.md b/ATTRIBUTIONS-Rust.md index 9a7732741..69a6974b7 100644 --- a/ATTRIBUTIONS-Rust.md +++ b/ATTRIBUTIONS-Rust.md @@ -9495,6 +9495,216 @@ SOFTWARE. ``` +## deranged - 0.5.8 +**Repository URL**: https://github.com/jhpratt/deranged +**License Type(s)**: Apache-2.0 +### License: https://spdx.org/licenses/Apache-2.0.html +``` + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product 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 NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of 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 reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2024 Jacob Pratt et al. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +``` + ## dialoguer - 0.11.0 **Repository URL**: https://github.com/console-rs/dialoguer **License Type(s)**: MIT @@ -13124,8 +13334,8 @@ limitations under the License. ``` -## futures - 0.3.32 -**Repository URL**: https://github.com/rust-lang/futures-rs +## fs2 - 0.4.3 +**Repository URL**: https://github.com/danburkert/fs2-rs **License Type(s)**: Apache-2.0 ### License: https://spdx.org/licenses/Apache-2.0.html ``` @@ -13317,8 +13527,7 @@ APPENDIX: How to apply the Apache License to your work. same "printed page" as the copyright notice for easier identification within third-party archives. -Copyright (c) 2016 Alex Crichton -Copyright (c) 2017 The Tokio Authors +Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -13334,7 +13543,7 @@ limitations under the License. ``` -## futures-channel - 0.3.32 +## futures - 0.3.32 **Repository URL**: https://github.com/rust-lang/futures-rs **License Type(s)**: Apache-2.0 ### License: https://spdx.org/licenses/Apache-2.0.html @@ -13544,7 +13753,7 @@ limitations under the License. ``` -## futures-core - 0.3.32 +## futures-channel - 0.3.32 **Repository URL**: https://github.com/rust-lang/futures-rs **License Type(s)**: Apache-2.0 ### License: https://spdx.org/licenses/Apache-2.0.html @@ -13754,7 +13963,7 @@ limitations under the License. ``` -## futures-executor - 0.3.32 +## futures-core - 0.3.32 **Repository URL**: https://github.com/rust-lang/futures-rs **License Type(s)**: Apache-2.0 ### License: https://spdx.org/licenses/Apache-2.0.html @@ -13964,7 +14173,7 @@ limitations under the License. ``` -## futures-io - 0.3.32 +## futures-executor - 0.3.32 **Repository URL**: https://github.com/rust-lang/futures-rs **License Type(s)**: Apache-2.0 ### License: https://spdx.org/licenses/Apache-2.0.html @@ -14174,7 +14383,7 @@ limitations under the License. ``` -## futures-macro - 0.3.32 +## futures-io - 0.3.32 **Repository URL**: https://github.com/rust-lang/futures-rs **License Type(s)**: Apache-2.0 ### License: https://spdx.org/licenses/Apache-2.0.html @@ -14384,7 +14593,7 @@ limitations under the License. ``` -## futures-sink - 0.3.32 +## futures-macro - 0.3.32 **Repository URL**: https://github.com/rust-lang/futures-rs **License Type(s)**: Apache-2.0 ### License: https://spdx.org/licenses/Apache-2.0.html @@ -14594,7 +14803,7 @@ limitations under the License. ``` -## futures-task - 0.3.32 +## futures-sink - 0.3.32 **Repository URL**: https://github.com/rust-lang/futures-rs **License Type(s)**: Apache-2.0 ### License: https://spdx.org/licenses/Apache-2.0.html @@ -14804,7 +15013,7 @@ limitations under the License. ``` -## futures-util - 0.3.32 +## futures-task - 0.3.32 **Repository URL**: https://github.com/rust-lang/futures-rs **License Type(s)**: Apache-2.0 ### License: https://spdx.org/licenses/Apache-2.0.html @@ -15014,42 +15223,14 @@ limitations under the License. ``` -## generic-array - 0.14.7 -**Repository URL**: https://github.com/fizyk20/generic-array.git -**License Type(s)**: MIT -### License: https://spdx.org/licenses/MIT.html -``` -The MIT License (MIT) - -Copyright (c) 2015 Bartłomiej Kamiński - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. -``` - -## getrandom - 0.2.17 -**Repository URL**: https://github.com/rust-random/getrandom +## futures-util - 0.3.32 +**Repository URL**: https://github.com/rust-lang/futures-rs **License Type(s)**: Apache-2.0 ### License: https://spdx.org/licenses/Apache-2.0.html ``` Apache License Version 2.0, January 2004 - https://www.apache.org/licenses/ + http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION @@ -15235,13 +15416,14 @@ APPENDIX: How to apply the Apache License to your work. same "printed page" as the copyright notice for easier identification within third-party archives. -Copyright [yyyy] [name of copyright owner] +Copyright (c) 2016 Alex Crichton +Copyright (c) 2017 The Tokio Authors Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at - https://www.apache.org/licenses/LICENSE-2.0 + http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, @@ -15251,7 +15433,35 @@ limitations under the License. ``` -## getrandom - 0.3.4 +## generic-array - 0.14.7 +**Repository URL**: https://github.com/fizyk20/generic-array.git +**License Type(s)**: MIT +### License: https://spdx.org/licenses/MIT.html +``` +The MIT License (MIT) + +Copyright (c) 2015 Bartłomiej Kamiński + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +``` + +## getrandom - 0.2.17 **Repository URL**: https://github.com/rust-random/getrandom **License Type(s)**: Apache-2.0 ### License: https://spdx.org/licenses/Apache-2.0.html @@ -15460,7 +15670,7 @@ limitations under the License. ``` -## getrandom - 0.4.2 +## getrandom - 0.3.4 **Repository URL**: https://github.com/rust-random/getrandom **License Type(s)**: Apache-2.0 ### License: https://spdx.org/licenses/Apache-2.0.html @@ -15669,47 +15879,14 @@ limitations under the License. ``` -## h2 - 0.4.13 -**Repository URL**: https://github.com/hyperium/h2 -**License Type(s)**: MIT -### License: https://spdx.org/licenses/MIT.html -``` -Copyright (c) 2017 h2 authors - -Permission is hereby granted, free of charge, to any -person obtaining a copy of this software and associated -documentation files (the "Software"), to deal in the -Software without restriction, including without -limitation the rights to use, copy, modify, merge, -publish, distribute, sublicense, and/or sell copies of -the Software, and to permit persons to whom the Software -is furnished to do so, subject to the following -conditions: - -The above copyright notice and this permission notice -shall be included in all copies or substantial portions -of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF -ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED -TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A -PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT -SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION -OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR -IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -DEALINGS IN THE SOFTWARE. - -``` - -## hashbrown - 0.15.5 -**Repository URL**: https://github.com/rust-lang/hashbrown +## getrandom - 0.4.2 +**Repository URL**: https://github.com/rust-random/getrandom **License Type(s)**: Apache-2.0 ### License: https://spdx.org/licenses/Apache-2.0.html ``` Apache License Version 2.0, January 2004 - http://www.apache.org/licenses/ + https://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION @@ -15901,7 +16078,7 @@ Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at - http://www.apache.org/licenses/LICENSE-2.0 + https://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, @@ -15911,216 +16088,40 @@ limitations under the License. ``` -## hashbrown - 0.16.1 -**Repository URL**: https://github.com/rust-lang/hashbrown -**License Type(s)**: Apache-2.0 -### License: https://spdx.org/licenses/Apache-2.0.html +## h2 - 0.4.13 +**Repository URL**: https://github.com/hyperium/h2 +**License Type(s)**: MIT +### License: https://spdx.org/licenses/MIT.html ``` - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - -TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - -1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - -2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - -3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - -4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - -5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - -6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product 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 NOTICE file. - -7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - -8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - -9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of 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 reason - of your accepting any such warranty or additional liability. - -END OF TERMS AND CONDITIONS - -APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - -Copyright [yyyy] [name of copyright owner] +Copyright (c) 2017 h2 authors -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: - http://www.apache.org/licenses/LICENSE-2.0 +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. ``` -## hashbrown - 0.17.0 +## hashbrown - 0.15.5 **Repository URL**: https://github.com/rust-lang/hashbrown **License Type(s)**: Apache-2.0 ### License: https://spdx.org/licenses/Apache-2.0.html @@ -16329,8 +16330,8 @@ limitations under the License. ``` -## heck - 0.5.0 -**Repository URL**: https://github.com/withoutboats/heck +## hashbrown - 0.16.1 +**Repository URL**: https://github.com/rust-lang/hashbrown **License Type(s)**: Apache-2.0 ### License: https://spdx.org/licenses/Apache-2.0.html ``` @@ -16538,8 +16539,8 @@ limitations under the License. ``` -## http - 1.4.0 -**Repository URL**: https://github.com/hyperium/http +## hashbrown - 0.17.0 +**Repository URL**: https://github.com/rust-lang/hashbrown **License Type(s)**: Apache-2.0 ### License: https://spdx.org/licenses/Apache-2.0.html ``` @@ -16731,7 +16732,7 @@ APPENDIX: How to apply the Apache License to your work. same "printed page" as the copyright notice for easier identification within third-party archives. -Copyright 2017 http-rs authors +Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -16747,74 +16748,492 @@ limitations under the License. ``` -## http-body - 1.0.1 -**Repository URL**: https://github.com/hyperium/http-body -**License Type(s)**: MIT -### License: https://spdx.org/licenses/MIT.html +## heck - 0.5.0 +**Repository URL**: https://github.com/withoutboats/heck +**License Type(s)**: Apache-2.0 +### License: https://spdx.org/licenses/Apache-2.0.html ``` -Copyright (c) 2019-2024 Sean McArthur & Hyper Contributors + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ -Permission is hereby granted, free of charge, to any -person obtaining a copy of this software and associated -documentation files (the "Software"), to deal in the -Software without restriction, including without -limitation the rights to use, copy, modify, merge, -publish, distribute, sublicense, and/or sell copies of -the Software, and to permit persons to whom the Software -is furnished to do so, subject to the following -conditions: +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION -The above copyright notice and this permission notice -shall be included in all copies or substantial portions -of the Software. +1. Definitions. -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF -ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED -TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A -PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT -SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION -OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR -IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -DEALINGS IN THE SOFTWARE. + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. -``` + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. -## http-body-util - 0.1.3 -**Repository URL**: https://github.com/hyperium/http-body -**License Type(s)**: MIT -### License: https://spdx.org/licenses/MIT.html -``` -Copyright (c) 2019-2025 Sean McArthur & Hyper Contributors + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. -Permission is hereby granted, free of charge, to any -person obtaining a copy of this software and associated -documentation files (the "Software"), to deal in the -Software without restriction, including without -limitation the rights to use, copy, modify, merge, -publish, distribute, sublicense, and/or sell copies of -the Software, and to permit persons to whom the Software -is furnished to do so, subject to the following -conditions: + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. -The above copyright notice and this permission notice -shall be included in all copies or substantial portions -of the Software. + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF -ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED -TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A -PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT -SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION -OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR -IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -DEALINGS IN THE SOFTWARE. + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product 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 NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of 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 reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. ``` -## httparse - 1.10.1 -**Repository URL**: https://github.com/seanmonstar/httparse +## http - 1.4.0 +**Repository URL**: https://github.com/hyperium/http +**License Type(s)**: Apache-2.0 +### License: https://spdx.org/licenses/Apache-2.0.html +``` + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product 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 NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of 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 reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright 2017 http-rs authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +``` + +## http-body - 1.0.1 +**Repository URL**: https://github.com/hyperium/http-body +**License Type(s)**: MIT +### License: https://spdx.org/licenses/MIT.html +``` +Copyright (c) 2019-2024 Sean McArthur & Hyper Contributors + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +``` + +## http-body-util - 0.1.3 +**Repository URL**: https://github.com/hyperium/http-body +**License Type(s)**: MIT +### License: https://spdx.org/licenses/MIT.html +``` +Copyright (c) 2019-2025 Sean McArthur & Hyper Contributors + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +``` + +## httparse - 1.10.1 +**Repository URL**: https://github.com/seanmonstar/httparse **License Type(s)**: Apache-2.0 ### License: https://spdx.org/licenses/Apache-2.0.html ``` @@ -24056,6 +24475,87 @@ limitations under the License. ``` +## num-conv - 0.2.2 +**Repository URL**: https://github.com/jhpratt/num-conv +**License Type(s)**: Apache-2.0 +### License: https://spdx.org/licenses/Apache-2.0.html +``` +Apache License +Version 2.0, January 2004 +http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + +"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. + +"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. + +"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. + +"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. + +"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. + +"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. + +"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). + +"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. + +"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." + +"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: + + (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. + + You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product 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 NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of 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 reason of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + +To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +``` + ## num-integer - 0.1.46 **Repository URL**: https://github.com/rust-num/num-integer **License Type(s)**: Apache-2.0 @@ -26906,217 +27406,246 @@ limitations under the License. ``` -## percent-encoding - 2.3.2 -**Repository URL**: https://github.com/servo/rust-url/ -**License Type(s)**: Apache-2.0 -### License: https://spdx.org/licenses/Apache-2.0.html +## pem - 3.0.6 +**Repository URL**: https://github.com/jcreekmore/pem-rs.git +**License Type(s)**: MIT +### License: https://spdx.org/licenses/MIT.html ``` - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - -TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - -1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - -2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - -3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - -4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - -5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - -6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product 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 NOTICE file. - -7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - -8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - -9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of 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 reason - of your accepting any such warranty or additional liability. - -END OF TERMS AND CONDITIONS - -APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. +The MIT License (MIT) -Copyright [yyyy] [name of copyright owner] +Copyright (c) 2016 Jonathan Creekmore -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: - http://www.apache.org/licenses/LICENSE-2.0 +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. ``` -## petgraph - 0.8.3 -**Repository URL**: https://github.com/petgraph/petgraph +## percent-encoding - 2.3.2 +**Repository URL**: https://github.com/servo/rust-url/ +**License Type(s)**: Apache-2.0 +### License: https://spdx.org/licenses/Apache-2.0.html +``` + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product 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 NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of 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 reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +``` + +## petgraph - 0.8.3 +**Repository URL**: https://github.com/petgraph/petgraph **License Type(s)**: Apache-2.0 ### License: https://spdx.org/licenses/Apache-2.0.html ``` @@ -27702,6 +28231,216 @@ ICU 1.8.1 to ICU 57.1 © 1995-2016 International Business Machines Corporation a ``` +## powerfmt - 0.2.0 +**Repository URL**: https://github.com/jhpratt/powerfmt +**License Type(s)**: Apache-2.0 +### License: https://spdx.org/licenses/Apache-2.0.html +``` + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product 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 NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of 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 reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2023 Jacob Pratt et al. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +``` + ## ppv-lite86 - 0.2.21 **Repository URL**: https://github.com/cryptocorrosion/cryptocorrosion **License Type(s)**: Apache-2.0 @@ -31577,6 +32316,87 @@ APPENDIX: How to apply the Apache License to your work. ``` +## rcgen - 0.13.2 +**Repository URL**: https://github.com/rustls/rcgen +**License Type(s)**: Apache-2.0 +### License: https://spdx.org/licenses/Apache-2.0.html +``` +Apache License +Version 2.0, January 2004 +http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + +"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. + +"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. + +"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. + +"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. + +"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. + +"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. + +"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). + +"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. + +"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." + +"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: + + (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. + + You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product 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 NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of 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 reason of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + +To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +``` + ## redis - 1.2.0 **Repository URL**: https://github.com/redis-rs/redis-rs **License Type(s)**: BSD-3-Clause @@ -38712,6 +39532,168 @@ limitations under the License. ``` +## time - 0.3.53 +**Repository URL**: https://github.com/time-rs/time +**License Type(s)**: Apache-2.0 +### License: https://spdx.org/licenses/Apache-2.0.html +``` +Apache License +Version 2.0, January 2004 +http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + +"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. + +"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. + +"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. + +"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. + +"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. + +"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. + +"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). + +"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. + +"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." + +"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: + + (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. + + You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product 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 NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of 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 reason of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + +To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +``` + +## time-core - 0.1.9 +**Repository URL**: https://github.com/time-rs/time +**License Type(s)**: Apache-2.0 +### License: https://spdx.org/licenses/Apache-2.0.html +``` +Apache License +Version 2.0, January 2004 +http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + +"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. + +"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. + +"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. + +"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. + +"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. + +"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. + +"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). + +"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. + +"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." + +"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: + + (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. + + You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product 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 NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of 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 reason of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + +To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +``` + ## tinystr - 0.8.3 **Repository URL**: https://github.com/unicode-org/icu4x **License Type(s)**: Unicode-3.0 @@ -46139,6 +47121,377 @@ limitations under the License. ``` +## winapi - 0.3.9 +**Repository URL**: https://github.com/retep998/winapi-rs +**License Type(s)**: Apache-2.0 +### License: https://spdx.org/licenses/Apache-2.0.html +``` + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product 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 NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of 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 reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright {yyyy} {name of copyright owner} + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +``` + +## winapi-i686-pc-windows-gnu - 0.4.0 +**Repository URL**: https://github.com/retep998/winapi-rs +**License Type(s)**: Apache-2.0 +### License: https://spdx.org/licenses/Apache-2.0.html +``` +Apache License +Version 2.0, January 2004 +http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + +"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. + +"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. + +"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. + +"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. + +"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. + +"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. + +"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). + +"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. + +"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." + +"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: + + (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. + + You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product 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 NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of 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 reason of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + +To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +``` + +## winapi-x86_64-pc-windows-gnu - 0.4.0 +**Repository URL**: https://github.com/retep998/winapi-rs +**License Type(s)**: Apache-2.0 +### License: https://spdx.org/licenses/Apache-2.0.html +``` +Apache License +Version 2.0, January 2004 +http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + +"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. + +"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. + +"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. + +"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. + +"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. + +"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. + +"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). + +"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. + +"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." + +"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: + + (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. + + You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product 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 NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of 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 reason of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + +To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +``` + ## windows-core - 0.62.2 **Repository URL**: https://github.com/microsoft/windows-rs **License Type(s)**: Apache-2.0 @@ -51645,6 +52998,87 @@ DEALINGS IN THE SOFTWARE. ``` +## yasna - 0.5.2 +**Repository URL**: https://github.com/qnighy/yasna.rs +**License Type(s)**: Apache-2.0 +### License: https://spdx.org/licenses/Apache-2.0.html +``` +Apache License +Version 2.0, January 2004 +http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + +"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. + +"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. + +"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. + +"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. + +"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. + +"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. + +"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). + +"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. + +"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." + +"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: + + (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. + + You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product 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 NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of 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 reason of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + +To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +``` + ## yoke - 0.8.2 **Repository URL**: https://github.com/unicode-org/icu4x **License Type(s)**: Unicode-3.0 diff --git a/Cargo.lock b/Cargo.lock index 3a2fd4dbc..0bb3bc8f8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -559,6 +559,12 @@ version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + [[package]] name = "dialoguer" version = "0.11.0" @@ -753,6 +759,16 @@ dependencies = [ "num", ] +[[package]] +name = "fs2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "futures" version = "0.3.32" @@ -1536,14 +1552,19 @@ dependencies = [ "axum", "base64", "bytes", + "chrono", "clap", "clap_complete", "console 0.16.3", "dialoguer", + "fs2", "futures-util", "http", "http-body-util", + "hyper", + "hyper-util", "jsonschema", + "libc", "nemo-relay", "nemo-relay-adaptive", "nemo-relay-pii-redaction", @@ -1551,22 +1572,30 @@ dependencies = [ "opentelemetry", "opentelemetry_sdk", "percent-encoding", + "rcgen", "regex", "reqwest", "ring", + "rustls", + "semver", "serde", "serde_json", "serde_yaml", "sha2", + "shell-words", "strum", + "subtle", + "syn", "tempfile", "thiserror 2.0.18", "tokio", + "tokio-rustls", "tokio-tungstenite", "toml", "toml_edit", "tower", "uuid", + "windows-sys 0.61.2", ] [[package]] @@ -1748,6 +1777,12 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + [[package]] name = "num-integer" version = "0.1.46" @@ -1972,6 +2007,16 @@ dependencies = [ "windows-link", ] +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -2030,6 +2075,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -2408,6 +2459,19 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" +[[package]] +name = "rcgen" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2" +dependencies = [ + "pem", + "ring", + "rustls-pki-types", + "time", + "yasna", +] + [[package]] name = "redis" version = "1.2.0" @@ -3053,6 +3117,25 @@ dependencies = [ "syn", ] +[[package]] +name = "time" +version = "0.3.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + [[package]] name = "tinystr" version = "0.8.3" @@ -3676,6 +3759,28 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-core" version = "0.62.2" @@ -3941,6 +4046,15 @@ version = "0.8.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3" +[[package]] +name = "yasna" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd" +dependencies = [ + "time", +] + [[package]] name = "yoke" version = "0.8.2" diff --git a/codecov.yml b/codecov.yml index 1cb5518db..55b332a21 100644 --- a/codecov.yml +++ b/codecov.yml @@ -69,7 +69,7 @@ component_management: - "crates/cli/src" statuses: - type: project - target: 88% + target: 90% threshold: 0.5% base: auto if_ci_failed: error @@ -137,8 +137,9 @@ ignore: - "crates/cli/tests/" # CLI TTY shells are exercised by smoke tests, but their prompt loops are # intentionally split away from testable model modules. - - "crates/cli/src/plugins.rs" - - "crates/cli/src/setup.rs" + - "crates/cli/src/plugins/mod.rs" + - "crates/cli/src/plugins/dynamic_editor.rs" + - "crates/cli/src/commands/configure/wizard.rs" - "**/tests-js/**" # The Node binding currently reports JS package coverage separately; exclude the # native Rust bridge until we have direct Rust-side coverage for this crate. diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index b4da6573a..2f3b6cf4a 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -34,34 +34,52 @@ async-stream = "0.3" axum = "0.8" base64 = "0.22" bytes = "1" +chrono = "0.4" clap = { version = "4", features = ["derive", "env"] } clap_complete = "4" console = "0.16" futures-util = "0.3" +fs2 = "0.4" http = "1" http-body-util = "0.1" +hyper = "1" +hyper-util = { version = "0.1", features = ["tokio"] } dialoguer = { version = "0.11", default-features = false, features = ["password"] } jsonschema = { version = "0.46.6", default-features = false } percent-encoding = "2" reqwest = { version = "0.12", default-features = false, features = ["charset", "http2", "json", "rustls-tls-native-roots", "stream"] } regex = "1" +rcgen = "0.13" ring = "0.17" +rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] } +semver = "1" serde = { version = "1", features = ["derive"] } serde_json = "1" serde_yaml = "0.9" sha2 = "0.11" strum = { version = "0.27", features = ["derive"] } +subtle = "2" thiserror = "2" -tokio = { version = "1", features = ["macros", "net", "process", "rt-multi-thread", "signal", "sync", "time"] } +tokio = { version = "1", features = ["io-std", "io-util", "macros", "net", "process", "rt-multi-thread", "signal", "sync", "time"] } +tokio-rustls = { version = "0.26", default-features = false, features = ["ring", "tls12"] } tokio-tungstenite = { version = "0.27", default-features = false, features = ["connect", "rustls-tls-native-roots"] } toml = "0.9" toml_edit = "0.23" uuid = { workspace = true, features = ["serde", "v7"] } +[target.'cfg(unix)'.dependencies] +libc = "0.2" + +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Security", "Win32_Security_Authorization", "Win32_Storage_FileSystem", "Win32_System_Diagnostics_ToolHelp", "Win32_System_JobObjects", "Win32_System_SystemInformation", "Win32_System_Threading"] } + [dev-dependencies] opentelemetry = { workspace = true, features = ["trace"] } opentelemetry_sdk = { workspace = true, features = ["trace", "testing"] } +shell-words = "1" +syn = { version = "2", features = ["full", "visit"] } tempfile = "3" +tokio = { version = "1", features = ["test-util"] } tower = { version = "0.5", features = ["util"] } [[test]] diff --git a/crates/cli/src/adapters/claude_code.rs b/crates/cli/src/agents/claude/adapter.rs similarity index 96% rename from crates/cli/src/adapters/claude_code.rs rename to crates/cli/src/agents/claude/adapter.rs index 1492b2290..ed1cdde98 100644 --- a/crates/cli/src/adapters/claude_code.rs +++ b/crates/cli/src/agents/claude/adapter.rs @@ -4,10 +4,10 @@ use axum::http::HeaderMap; use serde_json::{Value, json}; -use crate::adapters::{ +use crate::agents::shared::adapters::{ AdapterOutcome, CLAUDE_CODE_PAYLOAD_EXTRACTOR, ClassificationRules, classify, }; -use crate::model::{AgentKind, NormalizedEvent}; +use crate::events::{AgentKind, NormalizedEvent}; /// Normalizes Claude Code hook payloads and returns the hook response Claude expects. /// diff --git a/crates/cli/src/alignment/claude_code.rs b/crates/cli/src/agents/claude/alignment.rs similarity index 95% rename from crates/cli/src/alignment/claude_code.rs rename to crates/cli/src/agents/claude/alignment.rs index 50880790e..006e5e381 100644 --- a/crates/cli/src/alignment/claude_code.rs +++ b/crates/cli/src/agents/claude/alignment.rs @@ -11,9 +11,9 @@ use axum::http::HeaderMap; use nemo_relay::api::llm::LlmRequest; use serde_json::Value; -use crate::alignment::json_string_at; -use crate::config::header_string; -use crate::model::{AgentKind, ToolEvent}; +use crate::agents::shared::alignment::json_string_at; +use crate::configuration::header_string; +use crate::events::{AgentKind, ToolEvent}; // Identifies gateway providers that should be labeled as Claude-owned when an Anthropic request // arrives before a SessionStart hook. Other providers are left generic so mixed gateway traffic @@ -109,5 +109,5 @@ fn has_terminal_agent_tool_evidence(result: &serde_json::Value) -> bool { } #[cfg(test)] -#[path = "../../tests/coverage/alignment_claude_code_tests.rs"] +#[path = "../../../tests/coverage/agents/alignment_claude_code_tests.rs"] mod tests; diff --git a/crates/cli/src/agents/claude/assets.rs b/crates/cli/src/agents/claude/assets.rs new file mode 100644 index 000000000..377845617 --- /dev/null +++ b/crates/cli/src/agents/claude/assets.rs @@ -0,0 +1,42 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use serde_json::{Value, json}; + +use crate::mcp::SERVER_NAME; + +pub(crate) fn marketplace_manifest(marketplace: &str, plugin: &str) -> Value { + json!({ + "name": marketplace, + "metadata": { "description": "Local NeMo Relay plugins for Claude Code." }, + "owner": { "name": "NVIDIA Corporation and Affiliates", "email": "noreply@nvidia.com" }, + "plugins": [{ + "name": plugin, + "description": "Run the shared native Relay gateway and capture Claude Code lifecycle events.", + "source": "./plugins/nemo-relay-plugin", + "category": "development" + }] + }) +} + +pub(crate) fn plugin_manifest(plugin: &str) -> Value { + json!({ + "name": plugin, + "version": env!("CARGO_PKG_VERSION"), + "description": "Native Relay gateway lifecycle and Claude Code hooks for complete local observability.", + "author": { "name": "NVIDIA Corporation and Affiliates", "url": "https://github.com/NVIDIA/NeMo-Relay" }, + "homepage": "https://github.com/NVIDIA/NeMo-Relay", + "repository": "https://github.com/NVIDIA/NeMo-Relay", + "license": "Apache-2.0", + "keywords": ["nemo-relay", "claude-code", "hooks", "observability"], + "mcpServers": "./.mcp.json" + }) +} + +pub(crate) fn mcp_config(mut server: Value) -> Value { + server + .as_object_mut() + .expect("persistent MCP server is a JSON object") + .insert("alwaysLoad".into(), json!(true)); + json!({ "mcpServers": { (SERVER_NAME): server } }) +} diff --git a/crates/cli/src/agents/claude/doctor.rs b/crates/cli/src/agents/claude/doctor.rs new file mode 100644 index 000000000..f398a62c0 --- /dev/null +++ b/crates/cli/src/agents/claude/doctor.rs @@ -0,0 +1,6 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +pub(crate) fn hook_status() -> Result { + Ok("hooks: injected during run".into()) +} diff --git a/crates/cli/src/agents/claude/host.rs b/crates/cli/src/agents/claude/host.rs new file mode 100644 index 000000000..ad541463d --- /dev/null +++ b/crates/cli/src/agents/claude/host.rs @@ -0,0 +1,232 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Claude Code-specific provider routing setup. + +use std::fs; +use std::path::{Path, PathBuf}; + +use serde_json::{Value, json}; + +use crate::agents::shared::host::{home_dir, read_json_object, write_json}; +use crate::filesystem::{ + FileSnapshot, backup, backup_path, remove_backup, restore_file_snapshot, snapshot_optional_file, +}; + +const ABSENT_SETTINGS_BACKUP_KEY: &str = "__nemo_relay_original_settings_absent"; +const MANAGED_PROVIDER_BACKUP_KEY: &str = "__nemo_relay_managed_anthropic_base_url"; + +pub(crate) struct ClaudeSetupSnapshot { + files: Vec, +} + +pub(crate) fn snapshot_claude_setup() -> Result { + let settings = claude_settings_path()?; + let files = [settings.clone(), backup_path(&settings)] + .iter() + .map(|path| snapshot_optional_file(path)) + .collect::, _>>()?; + Ok(ClaudeSetupSnapshot { files }) +} + +pub(crate) fn restore_claude_setup(snapshot: &ClaudeSetupSnapshot) -> Result<(), String> { + let errors = snapshot + .files + .iter() + .filter_map(|file| restore_file_snapshot(file).err()) + .collect::>(); + if errors.is_empty() { + Ok(()) + } else { + Err(errors.join("; ")) + } +} + +pub(crate) fn enable_claude_provider(gateway_url: &str) -> Result<(), String> { + let path = claude_settings_path()?; + let mut settings = read_json_object(&path)?; + if settings.get("env").is_some_and(|env| !env.is_object()) { + return Err(format!("{} has a non-object env field", path.display())); + } + let backup_snapshot = snapshot_optional_file(&backup_path(&path))?; + let current_provider = json_env_string(&settings, "ANTHROPIC_BASE_URL"); + let backup_file = backup_path(&path); + let previous_managed_provider = read_json_object(&backup_file).ok().and_then(|backup| { + backup + .get(MANAGED_PROVIDER_BACKUP_KEY) + .and_then(Value::as_str) + .map(str::to_owned) + }); + let managed_provider = current_provider == Some(gateway_url) + || previous_managed_provider + .as_deref() + .is_some_and(|previous| current_provider == Some(previous)) + || (backup_file.exists() && current_provider == Some(crate::bootstrap::DEFAULT_URL)); + if !managed_provider && let Err(error) = backup_claude_settings(&path, true) { + restore_file_snapshot(&backup_snapshot)?; + return Err(error); + } + if backup_file.exists() + && let Err(error) = record_managed_provider(&backup_file, gateway_url) + { + restore_file_snapshot(&backup_snapshot)?; + return Err(error); + } + let env = settings + .as_object_mut() + .expect("read_json_object returns an object") + .entry("env") + .or_insert_with(|| json!({})); + let env = env.as_object_mut().expect("env was validated as an object"); + env.insert("ANTHROPIC_BASE_URL".into(), json!(gateway_url)); + if let Err(error) = write_json(&path, &settings) { + restore_file_snapshot(&backup_snapshot)?; + return Err(error); + } + println!("set ANTHROPIC_BASE_URL={gateway_url} in {}", path.display()); + Ok(()) +} + +fn record_managed_provider(backup: &Path, gateway_url: &str) -> Result<(), String> { + let mut value = read_json_object(backup)?; + value + .as_object_mut() + .expect("read_json_object returns an object") + .insert(MANAGED_PROVIDER_BACKUP_KEY.into(), json!(gateway_url)); + write_json(backup, &value) +} + +pub(crate) fn restore_claude_provider(gateway_url: &str) -> Result<(), String> { + let path = claude_settings_path()?; + let backup = backup_path(&path); + if !backup.exists() { + println!( + "no backup found at {}; no managed Claude provider routing to restore", + backup.display() + ); + return Ok(()); + } + let mut settings = read_json_object(&path)?; + if json_env_string(&settings, "ANTHROPIC_BASE_URL") == Some(gateway_url) { + let backup_settings = read_json_object(&backup)?; + restore_json_env_value(&mut settings, &backup_settings, "ANTHROPIC_BASE_URL")?; + if backup_settings.get(ABSENT_SETTINGS_BACKUP_KEY) == Some(&Value::Bool(true)) + && settings.as_object().is_some_and(serde_json::Map::is_empty) + { + match fs::remove_file(&path) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + return Err(format!("failed to remove {}: {error}", path.display())); + } + } + } else { + write_json(&path, &settings)?; + } + remove_backup(&path)?; + println!( + "restored managed ANTHROPIC_BASE_URL in {} from {}", + path.display(), + backup.display() + ); + } else { + println!( + "current Claude provider routing is not managed by Relay; left {} unchanged", + path.display() + ); + } + Ok(()) +} + +pub(crate) fn json_env_string<'a>(value: &'a Value, key: &str) -> Option<&'a str> { + value + .get("env") + .and_then(Value::as_object) + .and_then(|env| env.get(key)) + .and_then(Value::as_str) +} + +pub(crate) fn remove_json_env_string(value: &mut Value, key: &str) -> Result { + let Some(object) = value.as_object_mut() else { + return Err("Claude settings must be a JSON object".into()); + }; + let Some(env) = object.get_mut("env") else { + return Ok(false); + }; + let Some(env) = env.as_object_mut() else { + return Err("Claude settings env field must be a JSON object".into()); + }; + let removed = env.remove(key).is_some(); + if env.is_empty() { + object.remove("env"); + } + Ok(removed) +} + +pub(crate) fn restore_json_env_value( + value: &mut Value, + backup: &Value, + key: &str, +) -> Result<(), String> { + let backup_value = backup + .get("env") + .and_then(Value::as_object) + .and_then(|env| env.get(key)) + .cloned(); + if let Some(backup_value) = backup_value { + let Some(object) = value.as_object_mut() else { + return Err("Claude settings must be a JSON object".into()); + }; + let env = object.entry("env").or_insert_with(|| json!({})); + let Some(env) = env.as_object_mut() else { + return Err("Claude settings env field must be a JSON object".into()); + }; + env.insert(key.into(), backup_value); + } else { + remove_json_env_string(value, key)?; + } + Ok(()) +} + +pub(crate) fn backup_claude_settings(path: &Path, replace_existing: bool) -> Result<(), String> { + let backup_file = backup_path(path); + if backup_file.exists() && !replace_existing { + return Ok(()); + } + if path.exists() { + if replace_existing && backup_file.exists() { + fs::remove_file(&backup_file).map_err(|error| { + format!( + "failed to remove stale backup {}: {error}", + backup_file.display() + ) + })?; + } + backup(path) + } else { + if let Some(parent) = backup_file.parent() { + fs::create_dir_all(parent) + .map_err(|error| format!("failed to create {}: {error}", parent.display()))?; + } + fs::write( + &backup_file, + format!("{{\"{ABSENT_SETTINGS_BACKUP_KEY}\":true}}\n"), + ) + .map_err(|error| format!("failed to write {}: {error}", backup_file.display())) + } +} + +pub(crate) fn claude_settings_path() -> Result { + Ok(home_dir()?.join(".claude").join("settings.json")) +} + +pub(crate) fn claude_settings_base_url() -> Option { + let path = claude_settings_path().ok()?; + let value = read_json_object(&path).ok()?; + value + .get("env") + .and_then(Value::as_object) + .and_then(|env| env.get("ANTHROPIC_BASE_URL")) + .and_then(Value::as_str) + .map(ToOwned::to_owned) +} diff --git a/crates/cli/src/agents/claude/install.rs b/crates/cli/src/agents/claude/install.rs new file mode 100644 index 000000000..338646a2c --- /dev/null +++ b/crates/cli/src/agents/claude/install.rs @@ -0,0 +1,16 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use std::process::ExitCode; + +use crate::agents::CodingAgent; +use crate::error::CliError; +use crate::installation::{InstallRequest, UninstallRequest}; + +pub(crate) fn install(command: InstallRequest) -> Result { + crate::installation::marketplace::install(CodingAgent::ClaudeCode, command) +} + +pub(crate) fn uninstall(command: UninstallRequest) -> Result { + crate::installation::marketplace::uninstall(CodingAgent::ClaudeCode, command) +} diff --git a/crates/cli/src/agents/claude/launch.rs b/crates/cli/src/agents/claude/launch.rs new file mode 100644 index 000000000..b86b3e61f --- /dev/null +++ b/crates/cli/src/agents/claude/launch.rs @@ -0,0 +1,172 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use std::path::{Path, PathBuf}; + +use serde_json::{Value, json}; + +use crate::agents::CodingAgent; +use crate::error::CliError; +use crate::hooks::{generated_hooks, transparent_hook_forward_command}; +use crate::process::{PreparedAgentLaunch, insert_after_host}; + +pub(crate) fn prepare( + launch: &mut PreparedAgentLaunch, + gateway_url: &str, + dry_run: bool, +) -> Result<(), CliError> { + if dry_run { + insert_after_host( + &mut launch.argv, + launch.host_index, + [ + "--plugin-dir".into(), + "".into(), + "--settings".into(), + "".into(), + ], + ); + launch + .env + .push(("ANTHROPIC_BASE_URL".into(), gateway_url.to_string())); + launch + .notes + .push("would generate a temporary Claude Code plugin directory".into()); + return Ok(()); + } + + let root = temp_dir("nemo-relay-claude-plugin")?; + std::fs::create_dir_all(root.join(".claude-plugin"))?; + std::fs::create_dir_all(root.join("hooks"))?; + std::fs::write( + root.join(".claude-plugin/plugin.json"), + serde_json::to_vec_pretty(&json!({ + "name": "nemo-relay-cli", + "version": env!("CARGO_PKG_VERSION"), + "description": "Temporary NeMo Relay gateway hooks" + })) + .map_err(|error| CliError::Launch(error.to_string()))?, + )?; + let hook_command = transparent_hook_forward_command( + &transparent_hook_executable(), + CodingAgent::ClaudeCode, + gateway_url, + ) + .map_err(CliError::Launch)?; + write_hooks( + &root.join("hooks/hooks.json"), + generated_hooks(CodingAgent::ClaudeCode, &hook_command), + )?; + let settings_path = root.join("settings.json"); + let settings = settings_overlay(&launch.argv, launch.host_index, gateway_url)?; + let settings_bytes = serde_json::to_vec_pretty(&settings) + .map_err(|error| CliError::Launch(error.to_string()))?; + crate::filesystem::atomic_write_private(&settings_path, &settings_bytes) + .map_err(CliError::Launch)?; + insert_after_host( + &mut launch.argv, + launch.host_index, + [ + "--plugin-dir".into(), + root.display().to_string(), + "--settings".into(), + settings_path.display().to_string(), + ], + ); + launch + .env + .push(("ANTHROPIC_BASE_URL".into(), gateway_url.to_string())); + launch.temp_dirs.push(root); + Ok(()) +} + +pub(crate) fn settings_overlay( + argv: &[String], + host_index: usize, + gateway_url: &str, +) -> Result { + let mut settings = match first_settings(argv, host_index)? { + Some(source) => read_settings(source)?, + None => json!({}), + }; + let object = settings.as_object_mut().ok_or_else(|| { + CliError::Launch("Claude Code --settings must contain a JSON object".into()) + })?; + let environment = object.entry("env").or_insert_with(|| json!({})); + let environment = environment.as_object_mut().ok_or_else(|| { + CliError::Launch("Claude Code --settings field `env` must be a JSON object".into()) + })?; + environment.insert( + "ANTHROPIC_BASE_URL".into(), + Value::String(gateway_url.into()), + ); + Ok(settings) +} + +fn first_settings(argv: &[String], host_index: usize) -> Result, CliError> { + let boundary = argv + .iter() + .skip(host_index + 1) + .position(|argument| argument == "--") + .map_or(argv.len(), |offset| host_index + 1 + offset); + let mut index = host_index + 1; + while index < boundary { + if argv[index] == "--settings" { + if index + 1 >= boundary || argv[index + 1].is_empty() { + return Err(CliError::Launch( + "Claude Code --settings is missing its value".into(), + )); + } + return Ok(Some(argv[index + 1].as_str())); + } + if let Some(value) = argv[index].strip_prefix("--settings=") { + if value.is_empty() { + return Err(CliError::Launch( + "Claude Code --settings is missing its value".into(), + )); + } + return Ok(Some(value)); + } + index += 1; + } + Ok(None) +} + +fn read_settings(source: &str) -> Result { + let raw = if source.trim_start().starts_with('{') { + source.to_string() + } else { + std::fs::read_to_string(source).map_err(|error| { + CliError::Launch(format!( + "failed to read Claude Code settings {}: {error}", + Path::new(source).display() + )) + })? + }; + serde_json::from_str(&raw).map_err(|error| { + CliError::Launch(format!( + "failed to parse Claude Code --settings JSON: {error}" + )) + }) +} + +fn transparent_hook_executable() -> PathBuf { + std::env::current_exe() + .map(|path| path.canonicalize().unwrap_or(path)) + .map(crate::agents::portable_executable_path) + .unwrap_or_else(|_| PathBuf::from("nemo-relay")) +} + +pub(crate) fn write_hooks(path: &Path, hooks: Value) -> Result<(), CliError> { + std::fs::write( + path, + serde_json::to_vec_pretty(&hooks).map_err(|error| CliError::Launch(error.to_string()))?, + )?; + Ok(()) +} + +fn temp_dir(prefix: &str) -> Result { + let path = std::env::temp_dir().join(format!("{prefix}-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&path)?; + Ok(path) +} diff --git a/crates/cli/src/agents/claude/mod.rs b/crates/cli/src/agents/claude/mod.rs new file mode 100644 index 000000000..b08ab9b11 --- /dev/null +++ b/crates/cli/src/agents/claude/mod.rs @@ -0,0 +1,43 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use semver::Version; + +use super::AgentDescriptor; + +pub(super) mod assets; +pub(crate) mod doctor; +pub(super) mod host; +pub(crate) mod install; +pub(crate) mod launch; + +pub(super) const DESCRIPTOR: AgentDescriptor = AgentDescriptor { + argument: "claude", + install_argument: "claude-code", + label: "Claude Code", + executable: "claude", + hook_path: "/hooks/claude-code", + version_product: "Claude Code", + minimum_version: (2, 1, 121), + hook_events: &[ + "SessionStart", + "UserPromptSubmit", + "UserPromptExpansion", + "PreToolUse", + "PostToolUse", + "PostToolUseFailure", + "PermissionRequest", + "SubagentStart", + "SubagentStop", + "Notification", + "Stop", + "PreCompact", + "PostCompact", + "SessionEnd", + ], + direct_hook_entries: false, +}; + +pub(super) fn parse_version(raw: &str) -> Option { + Version::parse(raw.strip_suffix(" (Claude Code)")?).ok() +} diff --git a/crates/cli/src/adapters/codex.rs b/crates/cli/src/agents/codex/adapter.rs similarity index 90% rename from crates/cli/src/adapters/codex.rs rename to crates/cli/src/agents/codex/adapter.rs index 4cbde84b4..536f778ba 100644 --- a/crates/cli/src/adapters/codex.rs +++ b/crates/cli/src/agents/codex/adapter.rs @@ -4,8 +4,10 @@ use axum::http::HeaderMap; use serde_json::{Value, json}; -use crate::adapters::{AdapterOutcome, CODEX_PAYLOAD_EXTRACTOR, ClassificationRules, classify}; -use crate::model::AgentKind; +use crate::agents::shared::adapters::{ + AdapterOutcome, CODEX_PAYLOAD_EXTRACTOR, ClassificationRules, classify, +}; +use crate::events::AgentKind; /// Normalizes Codex hook payloads while leaving Codex hook control flow untouched. /// diff --git a/crates/cli/src/alignment/codex.rs b/crates/cli/src/agents/codex/alignment.rs similarity index 87% rename from crates/cli/src/alignment/codex.rs rename to crates/cli/src/agents/codex/alignment.rs index ee10461da..ecdc84003 100644 --- a/crates/cli/src/alignment/codex.rs +++ b/crates/cli/src/agents/codex/alignment.rs @@ -13,10 +13,10 @@ use std::io::{BufRead, BufReader}; use axum::http::HeaderMap; use serde_json::{Map, Value, json}; -use crate::alignment::{ +use crate::agents::shared::alignment::{ GatewayRouteKind, SessionAlias, insert_optional, json_string_at, merge_metadata, }; -use crate::model::{AgentKind, SessionEvent, SubagentEvent}; +use crate::events::{AgentKind, SessionEvent, SubagentEvent}; // ChatGPT backend base URL used by Codex when authenticated with ChatGPT. This mirrors Codex's own // `CHATGPT_CODEX_BASE_URL`; API-key auth continues through the normal OpenAI base. @@ -40,22 +40,43 @@ pub(crate) fn owns_gateway_provider(provider: &str) -> bool { provider == "openai.responses" } -// Codex currently does not forward a stable session header on OpenAI Responses requests. When the -// request carries Codex client metadata, the `prompt_cache_key` is the rollout/thread id. The -// metadata check prevents treating arbitrary application prompt-cache keys as session ids. -pub(crate) fn prompt_cache_session_id(body: &Value, route: GatewayRouteKind) -> Option { +// Codex sends the hook-compatible root session id in Responses client metadata while retaining +// the rollout/thread id as `prompt_cache_key`. Prefer the shared session id so root and subagent +// provider traffic joins the hook session, then keep the prompt-cache fallback for older clients. +// The installation metadata check prevents treating arbitrary application identifiers as Codex +// session ids. +pub(crate) fn responses_session_id(body: &Value, route: GatewayRouteKind) -> Option { if route != GatewayRouteKind::OpenAiResponses { return None; } - let has_codex_metadata = body - .get("client_metadata") - .and_then(|metadata| metadata.get("x-codex-installation-id")) - .and_then(Value::as_str) - .is_some_and(|value| !value.is_empty()); - if !has_codex_metadata { + if !has_codex_client_metadata(body) { return None; } - json_string_at(body, &[&["prompt_cache_key"][..]]) + json_string_at( + body, + &[ + &["client_metadata", "session_id"][..], + &["prompt_cache_key"][..], + ], + ) +} + +// Codex identifies thread-spawned subagent provider calls with `collab_spawn`. The shared +// `session_id` remains the root hook session, while `thread_id` matches the lifecycle `agent_id`. +// Other internal request kinds remain root-session activity rather than gaining unmatched +// subagent ownership. +pub(crate) fn responses_subagent_id(body: &Value, route: GatewayRouteKind) -> Option { + if route != GatewayRouteKind::OpenAiResponses || !has_codex_client_metadata(body) { + return None; + } + let kind = json_string_at(body, &[&["client_metadata", "x-openai-subagent"][..]]); + (kind.as_deref() == Some("collab_spawn")) + .then(|| json_string_at(body, &[&["client_metadata", "thread_id"][..]])) + .flatten() +} + +fn has_codex_client_metadata(body: &Value) -> bool { + json_string_at(body, &[&["client_metadata", "x-codex-installation-id"][..]]).is_some() } // Gives the gateway a Codex-native upstream only when the inbound token is a recognized ChatGPT @@ -342,5 +363,5 @@ pub(crate) fn llm_owner_metadata(scope_metadata: Option<&Value>) -> Value { } #[cfg(test)] -#[path = "../../tests/coverage/alignment_codex_tests.rs"] +#[path = "../../../tests/coverage/agents/alignment_codex_tests.rs"] mod tests; diff --git a/crates/cli/src/agents/codex/app_server.rs b/crates/cli/src/agents/codex/app_server.rs new file mode 100644 index 000000000..3f6d1ca7c --- /dev/null +++ b/crates/cli/src/agents/codex/app_server.rs @@ -0,0 +1,230 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Minimal synchronous client for the stable Codex app-server hook APIs. + +use std::io::{BufRead, BufReader, Write}; +use std::path::Path; +use std::process::{Child, ChildStdin, Command, Stdio}; +use std::sync::mpsc::{self, Receiver}; +use std::thread; +use std::time::{Duration, Instant}; + +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; + +const REQUEST_TIMEOUT: Duration = Duration::from_secs(10); + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub(crate) struct CodexHookMetadata { + pub(crate) key: String, + pub(crate) event_name: String, + pub(crate) handler_type: String, + pub(crate) command: Option, + pub(crate) source_path: String, + pub(crate) source: String, + #[serde(default)] + pub(crate) plugin_id: Option, + pub(crate) enabled: bool, + pub(crate) current_hash: String, + pub(crate) trust_status: String, +} + +pub(crate) trait CodexHooksClient { + fn list_hooks(&mut self, cwd: &Path) -> Result, String>; + fn trust_hooks(&mut self, hooks: &[CodexHookMetadata]) -> Result<(), String>; + fn clear_hook_trust(&mut self, keys: &[String]) -> Result<(), String>; + fn restore_hook_trust(&mut self, state: &[(String, Option)]) -> Result<(), String>; +} + +pub(crate) struct CodexAppServerClient { + child: Child, + stdin: ChildStdin, + messages: Receiver>, + next_id: u64, +} + +impl CodexAppServerClient { + pub(crate) fn start() -> Result { + let mut command = codex_app_server_command(); + let mut child = command + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .map_err(|error| format!("failed to start `codex app-server`: {error}"))?; + let stdin = child + .stdin + .take() + .ok_or_else(|| "failed to open Codex app-server stdin".to_string())?; + let stdout = child + .stdout + .take() + .ok_or_else(|| "failed to open Codex app-server stdout".to_string())?; + let (sender, messages) = mpsc::channel(); + thread::spawn(move || { + for line in BufReader::new(stdout).lines() { + let parsed = line + .map_err(|error| format!("failed to read Codex app-server response: {error}")) + .and_then(|line| { + serde_json::from_str(&line) + .map_err(|error| format!("invalid JSON from Codex app-server: {error}")) + }); + if sender.send(parsed).is_err() { + break; + } + } + }); + let mut client = Self { + child, + stdin, + messages, + next_id: 1, + }; + client.request( + "initialize", + json!({ + "clientInfo": { + "name": "nemo_relay", + "title": "NeMo Relay", + "version": env!("CARGO_PKG_VERSION") + } + }), + )?; + client.notify("initialized", None)?; + Ok(client) + } + + fn request(&mut self, method: &str, params: Value) -> Result { + let id = self.next_id; + self.next_id += 1; + self.write_message(&json!({"method": method, "id": id, "params": params}))?; + let deadline = Instant::now() + REQUEST_TIMEOUT; + loop { + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + return Err(format!( + "timed out waiting for Codex app-server `{method}` response" + )); + } + let message = self.messages.recv_timeout(remaining).map_err(|error| { + format!("timed out waiting for Codex app-server `{method}` response: {error}") + })??; + if message.get("id").and_then(Value::as_u64) != Some(id) { + continue; + } + if let Some(error) = message.get("error") { + return Err(format!("Codex app-server `{method}` failed: {error}")); + } + return message + .get("result") + .cloned() + .ok_or_else(|| format!("Codex app-server `{method}` response had no result")); + } + } + + fn notify(&mut self, method: &str, params: Option) -> Result<(), String> { + let mut message = json!({"method": method}); + if let Some(params) = params { + message["params"] = params; + } + self.write_message(&message) + } + + fn write_message(&mut self, message: &Value) -> Result<(), String> { + serde_json::to_writer(&mut self.stdin, message) + .map_err(|error| format!("failed to encode Codex app-server request: {error}"))?; + self.stdin + .write_all(b"\n") + .and_then(|_| self.stdin.flush()) + .map_err(|error| format!("failed to write Codex app-server request: {error}")) + } + + fn batch_write(&mut self, edits: Vec) -> Result<(), String> { + self.request( + "config/batchWrite", + json!({"edits": edits, "reloadUserConfig": true}), + )?; + Ok(()) + } +} + +impl CodexHooksClient for CodexAppServerClient { + fn list_hooks(&mut self, cwd: &Path) -> Result, String> { + let response = self.request("hooks/list", json!({"cwds": [cwd]}))?; + let entry = response + .get("data") + .and_then(Value::as_array) + .and_then(|data| data.first()) + .ok_or_else(|| "Codex app-server returned no hook-list entry".to_string())?; + if let Some(errors) = entry.get("errors").and_then(Value::as_array) + && !errors.is_empty() + { + return Err(format!("Codex app-server could not load hooks: {errors:?}")); + } + serde_json::from_value(entry.get("hooks").cloned().unwrap_or_else(|| json!([]))) + .map_err(|error| format!("invalid hooks/list response from Codex app-server: {error}")) + } + + fn trust_hooks(&mut self, hooks: &[CodexHookMetadata]) -> Result<(), String> { + let state = hooks + .iter() + .fold(serde_json::Map::new(), |mut state, hook| { + state.insert( + hook.key.clone(), + json!({"trusted_hash": hook.current_hash, "enabled": true}), + ); + state + }); + self.batch_write(vec![json!({ + "keyPath": "hooks.state", + "value": state, + "mergeStrategy": "upsert" + })]) + } + + fn clear_hook_trust(&mut self, keys: &[String]) -> Result<(), String> { + let edits = keys + .iter() + .map(|key| { + json!({ + "keyPath": hook_state_key_path(key), + "value": null, + "mergeStrategy": "upsert" + }) + }) + .collect(); + self.batch_write(edits) + } + + fn restore_hook_trust(&mut self, state: &[(String, Option)]) -> Result<(), String> { + let edits = state + .iter() + .map(|(key, value)| { + json!({ + "keyPath": hook_state_key_path(key), + "value": value, + "mergeStrategy": "upsert" + }) + }) + .collect(); + self.batch_write(edits) + } +} + +impl Drop for CodexAppServerClient { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +pub(crate) fn hook_state_key_path(key: &str) -> String { + let quoted = serde_json::to_string(key).expect("serializing a string cannot fail"); + format!("hooks.state.{quoted}") +} + +fn codex_app_server_command() -> Command { + crate::process::std_command(&["codex".into(), "app-server".into()]) +} diff --git a/crates/cli/src/agents/codex/assets.rs b/crates/cli/src/agents/codex/assets.rs new file mode 100644 index 000000000..a66412e56 --- /dev/null +++ b/crates/cli/src/agents/codex/assets.rs @@ -0,0 +1,68 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use serde_json::{Value, json}; + +use crate::mcp::SERVER_NAME; + +pub(crate) fn marketplace_manifest(marketplace: &str, plugin: &str) -> Value { + json!({ + "name": marketplace, + "interface": { "displayName": "NeMo Relay Local" }, + "plugins": [{ + "name": plugin, + "source": { "source": "local", "path": "./plugins/nemo-relay-plugin" }, + "policy": { "installation": "AVAILABLE", "authentication": "ON_INSTALL" }, + "category": "Coding" + }] + }) +} + +pub(crate) fn plugin_manifest(plugin: &str) -> Value { + json!({ + "name": plugin, + "version": env!("CARGO_PKG_VERSION"), + "description": "Native Relay gateway lifecycle and Codex hooks for complete local observability.", + "author": { "name": "NVIDIA Corporation and Affiliates", "url": "https://github.com/NVIDIA/NeMo-Relay" }, + "homepage": "https://github.com/NVIDIA/NeMo-Relay", + "repository": "https://github.com/NVIDIA/NeMo-Relay", + "license": "Apache-2.0", + "keywords": ["nemo-relay", "codex", "hooks", "observability"], + "mcpServers": "./.mcp.json", + "interface": { + "displayName": "NeMo Relay Plugin", + "shortDescription": "Run the native Relay gateway and capture Codex lifecycle events.", + "longDescription": "Starts the native nemo-relay gateway through a required lifecycle-bound MCP server, routes model traffic through it, and installs command hooks that preserve canonical Codex lifecycle payloads.", + "developerName": "NVIDIA", + "category": "Coding", + "capabilities": ["Read"], + "defaultPrompt": ["Capture this Codex session with NeMo Relay observability."], + "websiteURL": "https://github.com/NVIDIA/NeMo-Relay", + "brandColor": "#76B900" + } + }) +} + +pub(crate) fn mcp_config(mut server: Value) -> Result { + let fields = server + .as_object_mut() + .expect("persistent MCP server is a JSON object"); + fields.insert("env_vars".into(), json!(mcp_env_vars()?)); + fields.insert("required".into(), json!(true)); + fields.insert("startup_timeout_sec".into(), json!(20)); + Ok(json!({ (SERVER_NAME): server })) +} + +pub(crate) fn mcp_env_vars() -> Result, String> { + let environment = std::env::vars_os().filter_map(|(name, _)| name.into_string().ok()); + let config = + crate::configuration::user_plugin_runtime_config().map_err(|error| error.to_string())?; + Ok(mcp_env_vars_from(environment, config.as_ref())) +} + +pub(crate) fn mcp_env_vars_from( + environment: impl IntoIterator, + config: Option<&Value>, +) -> Vec { + crate::mcp_environment::forwarded_names(environment, config) +} diff --git a/crates/cli/src/agents/codex/doctor.rs b/crates/cli/src/agents/codex/doctor.rs new file mode 100644 index 000000000..f398a62c0 --- /dev/null +++ b/crates/cli/src/agents/codex/doctor.rs @@ -0,0 +1,6 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +pub(crate) fn hook_status() -> Result { + Ok("hooks: injected during run".into()) +} diff --git a/crates/cli/src/agents/codex/host.rs b/crates/cli/src/agents/codex/host.rs new file mode 100644 index 000000000..1e8fb6f96 --- /dev/null +++ b/crates/cli/src/agents/codex/host.rs @@ -0,0 +1,1621 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Codex-specific plugin setup, provider routing, and hook configuration. + +use std::collections::BTreeSet; +use std::env; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::ExitCode; + +use serde_json::{Value, json}; +use toml_edit::{DocumentMut, InlineTable, Item, Table, Value as TomlValue, value}; + +use crate::agents::CodingAgent; +use crate::configuration::{BOOTSTRAP_CLIENT_TOKEN_HEADER, BootstrapChallengeKey, RELAY_PLUGIN_ID}; +use crate::hooks::generated_hooks; +#[cfg(test)] +use crate::hooks::merge_hooks; + +use super::app_server::{CodexAppServerClient, CodexHookMetadata, CodexHooksClient}; +use crate::agents::shared::host::{ + atomic_write, atomic_write_private, current_exe, ensure_table, home_dir, read_json_object, + shell_quote, write_json, +}; +use crate::filesystem::{ + FileSnapshot, backup, backup_path, remove_backup, restore_file_snapshot, snapshot_optional_file, +}; +use crate::process::{portable_executable_path, shell_quote_arg_for_platform}; + +pub(crate) const CODEX_PLUGIN_ID: &str = RELAY_PLUGIN_ID; +pub(crate) const CODEX_PLUGIN_HOOK_KEY_PREFIX: &str = + "nemo-relay-plugin@nemo-relay-local:hooks/hooks.json:"; + +pub(crate) struct CodexSetupSnapshot { + files: Vec, + hooks: Vec, + trust_state: Vec<(String, Option)>, +} + +pub(crate) fn snapshot_codex_setup() -> Result { + let home = home_dir()?; + let codex_dir = codex_home_dir()?; + let config_path = codex_dir.join("config.toml"); + let hooks_path = codex_dir.join("hooks.json"); + let mut client = CodexAppServerClient::start()?; + let hooks = relay_codex_plugin_hooks(&mut client, &home)?; + let trust_state = snapshot_relay_owned_hook_trust_state(&config_path, &hooks)?; + let files = codex_install_snapshots(&config_path, &hooks_path)?; + Ok(CodexSetupSnapshot { + files, + hooks, + trust_state, + }) +} + +pub(crate) fn restore_codex_setup(snapshot: &CodexSetupSnapshot) -> Result<(), String> { + let mut errors = Vec::new(); + if let Err(error) = restore_codex_install_snapshots(&snapshot.files) { + errors.push(format!("failed to restore Codex files: {error}")); + } + match (home_dir(), CodexAppServerClient::start()) { + (Ok(home), Ok(mut client)) => { + if let Err(error) = client.restore_hook_trust(&snapshot.trust_state) { + errors.push(format!("failed to restore Codex hook trust: {error}")); + } else if let Err(error) = + verify_restored_hook_trust(&mut client, &home, &snapshot.hooks) + { + errors.push(error); + } + } + (Err(error), _) | (_, Err(error)) => errors.push(error), + } + if let Err(error) = restore_codex_install_snapshots(&snapshot.files) { + errors.push(format!("failed to restore exact Codex files: {error}")); + } + if errors.is_empty() { + Ok(()) + } else { + Err(errors.join("; ")) + } +} + +#[cfg(test)] +pub(crate) fn install_codex( + gateway_url: &str, + plugin_hooks_path: &Path, +) -> Result { + install_codex_with_generation(gateway_url, plugin_hooks_path, None) +} + +pub(crate) fn install_codex_with_generation( + gateway_url: &str, + plugin_hooks_path: &Path, + generation_token: Option<&str>, +) -> Result { + let expected_command = + expected_plugin_hook_command_with_token(plugin_hooks_path, generation_token)?; + validate_plugin_hooks(plugin_hooks_path, &expected_command)?; + install_codex_with_trust( + gateway_url, + &expected_command, + |home, config_path, command| { + let mut client = CodexAppServerClient::start()?; + auto_trust_codex_hooks(&mut client, home, config_path, command) + }, + ) +} + +pub(crate) fn install_codex_with_trust( + gateway_url: &str, + expected_command: &str, + trust_hooks: F, +) -> Result +where + F: FnOnce(&Path, &Path, &str) -> Result<(), String>, +{ + let home = home_dir()?; + let codex_dir = codex_home_dir()?; + fs::create_dir_all(&codex_dir) + .map_err(|error| format!("failed to create {}: {error}", codex_dir.display()))?; + let config_path = codex_dir.join("config.toml"); + let hooks_path = codex_dir.join("hooks.json"); + prepare_codex_config(&config_path)?; + let snapshots = codex_install_snapshots(&config_path, &hooks_path)?; + let install_result = remove_legacy_codex_hooks(&hooks_path) + .and_then(|()| install_codex_config(&config_path, gateway_url)) + .and_then(|()| trust_hooks(&home, &config_path, expected_command)); + if let Err(error) = install_result { + return match restore_codex_install_snapshots(&snapshots) { + Ok(()) => Err(error), + Err(rollback_error) => Err(format!( + "{error}; additionally failed to roll back Codex configuration: {rollback_error}" + )), + }; + } + println!("updated {}", config_path.display()); + if hooks_path.exists() { + println!("updated {}", hooks_path.display()); + } + println!("configured Codex Relay provider and plugin hooks; no daemon was installed."); + Ok(ExitCode::SUCCESS) +} + +pub(crate) fn uninstall_codex( + installed_gateway_url: &str, + _plugin_hooks_path: &Path, +) -> Result { + let mut client = CodexAppServerClient::start()?; + uninstall_codex_with_client(installed_gateway_url, Some(&mut client)) +} + +pub(crate) fn uninstall_codex_with_client( + installed_gateway_url: &str, + client: Option<&mut dyn CodexHooksClient>, +) -> Result { + let home = home_dir()?; + let codex_dir = codex_home_dir()?; + let config_path = codex_dir.join("config.toml"); + let hooks_path = codex_dir.join("hooks.json"); + let client = client + .ok_or_else(|| "Codex app-server is required to clear plugin hook trust".to_string())?; + let hooks = relay_codex_plugin_hooks(client, &home)?; + let trust_state = snapshot_relay_owned_hook_trust_state(&config_path, &hooks)?; + let trust_keys = trust_state + .iter() + .map(|(key, _)| key.clone()) + .collect::>(); + let snapshots = codex_install_snapshots(&config_path, &hooks_path)?; + let uninstall_result = + clear_and_verify_hook_trust(client, &home, &config_path, &hooks, &trust_keys) + .and_then(|()| uninstall_codex_hooks(&hooks_path, installed_gateway_url)) + .and_then(|has_remaining_hooks| { + uninstall_codex_config(&config_path, installed_gateway_url, has_remaining_hooks) + }); + if let Err(error) = uninstall_result { + return rollback_codex_uninstall(client, &home, &hooks, &trust_state, &snapshots, error); + } + println!("updated {}", config_path.display()); + println!("updated {}", hooks_path.display()); + println!("removed Codex Relay provider and plugin hook trust."); + Ok(ExitCode::SUCCESS) +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub(crate) struct CodexHookTrustReport { + trusted: Vec, + untrusted: Vec, + modified: Vec, + disabled: Vec, + missing_required: Vec, + duplicate_required: Vec, +} + +impl CodexHookTrustReport { + pub(crate) fn ready(&self) -> bool { + self.untrusted.is_empty() + && self.modified.is_empty() + && self.disabled.is_empty() + && self.missing_required.is_empty() + && self.duplicate_required.is_empty() + && !self.trusted.is_empty() + } + + pub(crate) fn to_json(&self) -> Value { + json!({ + "trusted": self.trusted, + "untrusted": self.untrusted, + "modified": self.modified, + "disabled": self.disabled, + "missing_required": self.missing_required, + "duplicate_required": self.duplicate_required, + }) + } + + pub(crate) fn summary(&self) -> String { + format!( + "untrusted={}, modified={}, disabled={}, missing required={}, duplicate required={}", + self.untrusted.len(), + self.modified.len(), + self.disabled.len(), + self.missing_required.join(", "), + self.duplicate_required.join(", ") + ) + } +} + +pub(crate) fn empty_codex_hook_trust_report() -> CodexHookTrustReport { + CodexHookTrustReport { + missing_required: CodingAgent::Codex + .hook_events() + .iter() + .map(|event| (*event).to_string()) + .collect(), + ..CodexHookTrustReport::default() + } +} + +pub(crate) fn codex_hook_trust_report( + plugin_hooks_path: &Path, +) -> Result { + codex_hook_trust_report_with_generation(plugin_hooks_path, None) +} + +pub(crate) fn codex_hook_trust_report_with_generation( + plugin_hooks_path: &Path, + generation_token: Option<&str>, +) -> Result { + let home = home_dir()?; + let expected_command = + expected_plugin_hook_command_with_token(plugin_hooks_path, generation_token)?; + validate_plugin_hooks(plugin_hooks_path, &expected_command)?; + let mut client = CodexAppServerClient::start()?; + codex_hook_trust_report_with_client(&mut client, &home, &expected_command) +} + +pub(crate) fn codex_hook_trust_report_with_client( + client: &mut dyn CodexHooksClient, + cwd: &Path, + expected_command: &str, +) -> Result { + let hooks = relay_codex_hooks(client, cwd, expected_command)?; + Ok(codex_hook_trust_report_for(&hooks)) +} + +pub(crate) fn auto_trust_codex_hooks( + client: &mut dyn CodexHooksClient, + cwd: &Path, + config_path: &Path, + expected_command: &str, +) -> Result<(), String> { + let hooks = relay_codex_hooks(client, cwd, expected_command)?; + let before = codex_hook_trust_report_for(&hooks); + if !before.missing_required.is_empty() || !before.duplicate_required.is_empty() { + return Err(format!( + "Codex must discover exactly one Relay handler per required event (missing: {}; duplicate: {})", + before.missing_required.join(", "), + before.duplicate_required.join(", ") + )); + } + let state = snapshot_hook_trust_state(config_path, &hooks)?; + let trust_result = client.trust_hooks(&hooks).and_then(|()| { + let verified_hooks = relay_codex_hooks(client, cwd, expected_command)?; + let verified = codex_hook_trust_report_for(&verified_hooks); + let unverified_targets = hooks + .iter() + .filter(|target| { + !verified_hooks.iter().any(|actual| { + actual.key == target.key + && actual.current_hash == target.current_hash + && actual.trust_status == "trusted" + && actual.enabled + }) + }) + .map(|hook| hook.key.as_str()) + .collect::>(); + if verified.ready() && unverified_targets.is_empty() { + Ok(()) + } else { + Err(format!( + "Codex did not enable and trust all generated Relay hooks: {}; unverified targeted hooks={}", + verified.summary(), + unverified_targets.join(", ") + )) + } + }); + if let Err(error) = trust_result { + return restore_hook_trust_after_failure( + client, + cwd, + expected_command, + &hooks, + &state, + error, + ); + } + Ok(()) +} + +fn relay_codex_hooks( + client: &mut dyn CodexHooksClient, + cwd: &Path, + expected_command: &str, +) -> Result, String> { + let hooks = relay_codex_plugin_hooks(client, cwd)? + .into_iter() + .filter(|hook| hook.command.as_deref() == Some(expected_command)) + .collect::>(); + validate_loaded_hook_sources(&hooks, expected_command)?; + Ok(hooks) +} + +fn validate_loaded_hook_sources( + hooks: &[CodexHookMetadata], + expected_command: &str, +) -> Result<(), String> { + let expected = generated_hooks(CodingAgent::Codex, expected_command); + let sources = hooks + .iter() + .map(|hook| hook.source_path.as_str()) + .collect::>(); + for source in sources { + let path = Path::new(source); + let actual = read_json_object(path)?; + if actual != expected { + return Err(format!( + "Codex loaded modified Relay hooks from {}; run `nemo-relay install codex --force`", + path.display() + )); + } + } + Ok(()) +} + +fn relay_codex_plugin_hooks( + client: &mut dyn CodexHooksClient, + cwd: &Path, +) -> Result, String> { + Ok(client + .list_hooks(cwd)? + .into_iter() + .filter(|hook| { + hook.source == "plugin" + && hook.plugin_id.as_deref() == Some(CODEX_PLUGIN_ID) + && hook.handler_type == "command" + && is_generated_codex_hook_event(&hook.event_name) + }) + .collect()) +} + +fn clear_and_verify_hook_trust( + client: &mut dyn CodexHooksClient, + cwd: &Path, + config_path: &Path, + hooks: &[CodexHookMetadata], + keys: &[String], +) -> Result<(), String> { + if keys.is_empty() { + return Ok(()); + } + client.clear_hook_trust(keys)?; + let mut uncleared = Vec::new(); + if !hooks.is_empty() { + let cleared = relay_codex_plugin_hooks(client, cwd)?; + uncleared.extend( + hooks + .iter() + .filter(|expected| { + !cleared.iter().any(|actual| { + actual.key == expected.key && actual.trust_status.as_str() == "untrusted" + }) + }) + .map(|hook| hook.key.clone()), + ); + } + let persisted = configured_hook_trust_keys(config_path)?; + uncleared.extend( + keys.iter() + .filter(|key| persisted.contains(key.as_str())) + .cloned(), + ); + uncleared.sort(); + uncleared.dedup(); + if uncleared.is_empty() { + Ok(()) + } else { + Err(format!( + "Codex did not clear trust for Relay plugin hooks: {}", + uncleared.join(", ") + )) + } +} + +pub(crate) fn configured_hook_trust_keys(config_path: &Path) -> Result, String> { + let raw = read_optional_text(config_path)?; + let config = toml::from_str::(&raw) + .map_err(|error| format!("invalid TOML in {}: {error}", config_path.display()))?; + Ok(config + .get("hooks") + .and_then(|hooks| hooks.get("state")) + .and_then(toml::Value::as_table) + .into_iter() + .flat_map(|state| state.keys()) + .cloned() + .collect()) +} + +fn relay_owned_hook_trust_keys( + config_path: &Path, + hooks: &[CodexHookMetadata], +) -> Result, String> { + let mut keys = Vec::new(); + for hook in hooks { + if !keys.contains(&hook.key) { + keys.push(hook.key.clone()); + } + } + for key in configured_hook_trust_keys(config_path)? { + if key.starts_with(CODEX_PLUGIN_HOOK_KEY_PREFIX) && !keys.contains(&key) { + keys.push(key); + } + } + Ok(keys) +} + +fn snapshot_relay_owned_hook_trust_state( + config_path: &Path, + hooks: &[CodexHookMetadata], +) -> Result)>, String> { + let keys = relay_owned_hook_trust_keys(config_path, hooks)?; + snapshot_hook_trust_keys(config_path, &keys) +} + +fn snapshot_hook_trust_keys( + config_path: &Path, + keys: &[String], +) -> Result)>, String> { + let raw = read_optional_text(config_path)?; + let config = toml::from_str::(&raw) + .map_err(|error| format!("invalid TOML in {}: {error}", config_path.display()))?; + let state = config + .get("hooks") + .and_then(|hooks| hooks.get("state")) + .and_then(toml::Value::as_table); + keys.iter() + .map(|key| { + let value = state + .and_then(|state| state.get(key)) + .map(serde_json::to_value) + .transpose() + .map_err(|error| { + format!("failed to snapshot Codex hook trust for {key}: {error}") + })?; + Ok((key.clone(), value)) + }) + .collect() +} + +fn snapshot_hook_trust_state( + config_path: &Path, + hooks: &[CodexHookMetadata], +) -> Result)>, String> { + let keys = hooks + .iter() + .map(|hook| hook.key.clone()) + .collect::>(); + snapshot_hook_trust_keys(config_path, &keys) +} + +fn rollback_codex_uninstall( + client: &mut dyn CodexHooksClient, + cwd: &Path, + hooks: &[CodexHookMetadata], + trust_state: &[(String, Option)], + snapshots: &[FileSnapshot], + original_error: String, +) -> Result { + let mut rollback_errors = Vec::new(); + if let Err(error) = client.restore_hook_trust(trust_state) { + rollback_errors.push(format!("failed to restore Codex hook trust: {error}")); + } else if let Err(error) = verify_restored_hook_trust(client, cwd, hooks) { + rollback_errors.push(error); + } + if let Err(error) = restore_codex_install_snapshots(snapshots) { + rollback_errors.push(format!("failed to restore Codex files: {error}")); + } + if rollback_errors.is_empty() { + Err(original_error) + } else { + Err(format!( + "{original_error}; additionally failed to roll back Codex uninstall: {}", + rollback_errors.join("; ") + )) + } +} + +fn verify_restored_hook_trust( + client: &mut dyn CodexHooksClient, + cwd: &Path, + expected: &[CodexHookMetadata], +) -> Result<(), String> { + if expected.is_empty() { + return Ok(()); + } + let restored = relay_codex_plugin_hooks(client, cwd)?; + let matches = expected.iter().all(|expected| { + restored.iter().any(|actual| { + actual.key == expected.key + && actual.trust_status == expected.trust_status + && actual.enabled == expected.enabled + }) + }); + matches + .then_some(()) + .ok_or_else(|| "failed to verify restored Codex hook trust after uninstall rollback".into()) +} + +fn restore_hook_trust_after_failure( + client: &mut dyn CodexHooksClient, + cwd: &Path, + expected_command: &str, + before: &[CodexHookMetadata], + state: &[(String, Option)], + original_error: String, +) -> Result<(), String> { + if let Err(rollback_error) = client.restore_hook_trust(state) { + return Err(format!( + "{original_error}; additionally failed to restore Codex hook trust: {rollback_error}" + )); + } + let restored = relay_codex_hooks(client, cwd, expected_command).map_err(|rollback_error| { + format!( + "{original_error}; additionally failed to verify restored Codex hook trust: {rollback_error}" + ) + })?; + let restored_matches = before.iter().all(|expected| { + restored.iter().any(|actual| { + actual.key == expected.key + && actual.trust_status == expected.trust_status + && actual.enabled == expected.enabled + }) + }); + if !restored_matches { + return Err(format!( + "{original_error}; additionally failed to verify restored Codex hook trust state" + )); + } + Err(original_error) +} + +#[cfg(test)] +pub(crate) fn expected_plugin_hook_command(plugin_hooks_path: &Path) -> Result { + expected_plugin_hook_command_with_token(plugin_hooks_path, None) +} + +fn expected_plugin_hook_command_with_token( + plugin_hooks_path: &Path, + generation_token: Option<&str>, +) -> Result { + let relay = current_exe()?; + let relay = relay.canonicalize().unwrap_or(relay); + let relay = portable_executable_path(relay); + let generation_path = plugin_generation_file(plugin_hooks_path)?; + let captured; + let generation_token = match generation_token { + Some(token) => token, + None => { + captured = crate::installation::generation::InstallGeneration::capture( + generation_path.clone(), + )?; + captured.token() + } + }; + codex_plugin_hook_command(&relay, &generation_path, generation_token) +} + +fn plugin_generation_file(plugin_hooks_path: &Path) -> Result { + let generation = plugin_hooks_path + .parent() + .and_then(Path::parent) + .map(|root| root.join(crate::installation::generation::GENERATION_FILE_NAME)) + .ok_or_else(|| { + format!( + "Codex plugin hooks path {} is not inside a plugin hooks directory", + plugin_hooks_path.display() + ) + })?; + if generation.is_absolute() { + Ok(generation) + } else { + env::current_dir() + .map(|current| current.join(generation)) + .map_err(|error| format!("failed to resolve the Codex plugin generation path: {error}")) + } +} + +fn validate_plugin_hooks(path: &Path, expected_command: &str) -> Result<(), String> { + let actual = read_json_object(path)?; + let expected = generated_hooks(CodingAgent::Codex, expected_command); + if actual == expected { + Ok(()) + } else { + Err(format!( + "{} does not match the generated NeMo Relay plugin hooks; run `nemo-relay install codex --force`", + path.display() + )) + } +} + +pub(crate) fn codex_hook_trust_report_for(hooks: &[CodexHookMetadata]) -> CodexHookTrustReport { + let mut report = CodexHookTrustReport::default(); + for hook in hooks { + match hook.trust_status.as_str() { + "trusted" => report.trusted.push(hook.key.clone()), + "modified" => report.modified.push(hook.key.clone()), + _ => report.untrusted.push(hook.key.clone()), + } + if !hook.enabled { + report.disabled.push(hook.key.clone()); + } + } + report.missing_required = CodingAgent::Codex + .hook_events() + .iter() + .filter(|event| { + let normalized = normalize_hook_event(event); + !hooks + .iter() + .any(|hook| normalize_hook_event(&hook.event_name) == normalized) + }) + .map(|event| (*event).to_string()) + .collect(); + report.duplicate_required = CodingAgent::Codex + .hook_events() + .iter() + .filter(|event| { + let normalized = normalize_hook_event(event); + hooks + .iter() + .filter(|hook| normalize_hook_event(&hook.event_name) == normalized) + .count() + > 1 + }) + .map(|event| (*event).to_string()) + .collect(); + report +} + +fn is_generated_codex_hook_event(event: &str) -> bool { + let normalized = normalize_hook_event(event); + CodingAgent::Codex + .hook_events() + .iter() + .any(|expected| normalize_hook_event(expected) == normalized) +} + +fn normalize_hook_event(event: &str) -> String { + event + .chars() + .filter(|character| character.is_ascii_alphanumeric()) + .flat_map(char::to_lowercase) + .collect() +} + +fn codex_install_snapshots( + config_path: &Path, + hooks_path: &Path, +) -> Result, String> { + [ + config_path.to_path_buf(), + backup_path(config_path), + hooks_path.to_path_buf(), + backup_path(hooks_path), + ] + .iter() + .map(|path| snapshot_optional_file(path)) + .collect() +} + +fn restore_codex_install_snapshots(snapshots: &[FileSnapshot]) -> Result<(), String> { + let errors = snapshots + .iter() + .filter_map(|snapshot| restore_file_snapshot(snapshot).err()) + .collect::>(); + if errors.is_empty() { + Ok(()) + } else { + Err(errors.join("; ")) + } +} + +pub(crate) fn prepare_codex_config(path: &Path) -> Result<(), String> { + let raw = read_optional_text(path)?; + raw.parse::() + .map(|_| ()) + .map_err(|error| format!("invalid TOML in {}: {error}", path.display())) +} + +pub(crate) fn install_codex_config(path: &Path, gateway_url: &str) -> Result<(), String> { + let challenge = BootstrapChallengeKey::load().map_err(|error| error.to_string())?; + let client_token = challenge.client_token(); + let raw = read_optional_text(path)?; + let mut doc = raw + .parse::() + .map_err(|error| format!("invalid TOML in {}: {error}", path.display()))?; + let backup_snapshot = snapshot_optional_file(&backup_path(path))?; + let has_managed_proof = + codex_provider_client_token(&doc).is_some_and(|token| challenge.verify_client_token(token)); + let provider_extensions = codex_provider_user_extensions(&doc, gateway_url); + let unmodified_managed_install = codex_config_doc_has_managed_install(&doc, gateway_url) + && has_managed_proof + && codex_provider_has_only_generated_fields(&doc); + if !unmodified_managed_install + && let Err(error) = refresh_codex_config_backup( + path, + &raw, + &doc, + gateway_url, + has_managed_proof, + &challenge, + ) + { + return match restore_file_snapshot(&backup_snapshot) { + Ok(()) => Err(error), + Err(restore_error) => Err(format!( + "{error}; additionally failed to restore the Codex backup: {restore_error}" + )), + }; + } + doc["model_provider"] = value("nemo-relay-openai"); + ensure_table(&mut doc, "features")["hooks"] = value(true); + + let providers = ensure_table(&mut doc, "model_providers"); + let mut provider = Table::new(); + provider["name"] = value("NeMo Relay"); + provider["base_url"] = value(gateway_url); + provider["wire_api"] = value("responses"); + provider["requires_openai_auth"] = value(true); + provider["supports_websockets"] = value(false); + let mut headers = InlineTable::new(); + headers.insert(BOOTSTRAP_CLIENT_TOKEN_HEADER, TomlValue::from(client_token)); + provider["http_headers"] = Item::Value(TomlValue::InlineTable(headers)); + providers["nemo-relay-openai"] = Item::Table(provider); + if let Some(extensions) = provider_extensions.as_ref() { + merge_codex_provider_extensions(&mut doc, extensions); + } + + if let Err(error) = atomic_write_private(path, doc.to_string().as_bytes()) { + restore_file_snapshot(&backup_snapshot)?; + return Err(error); + } + Ok(()) +} + +/// Refresh the uninstall baseline without carrying installer-owned fields forward. +/// +/// A user can edit one field of an installed config before a forced reinstall. The current file +/// then contains both that user change and Relay's provider, hook flag, and client proof. Reusing +/// the whole file as the new backup would make those generated fields survive uninstall. Apply +/// the same ownership rules as uninstall to reconstruct the user baseline first. +fn refresh_codex_config_backup( + path: &Path, + raw: &str, + current: &DocumentMut, + gateway_url: &str, + has_managed_proof: bool, + challenge: &BootstrapChallengeKey, +) -> Result<(), String> { + let previous = read_codex_backup_doc_for_refresh(path)? + .map(|backup| sanitize_codex_backup_doc(backup, gateway_url, Some(challenge))); + if previous.is_none() && !has_managed_proof { + if !path.exists() { + return Ok(()); + } + return atomic_write_private(&backup_path(path), raw.as_bytes()); + } + + let empty = DocumentMut::new(); + let previous = previous.as_ref().unwrap_or(&empty); + let mut baseline = current.clone(); + let preserved_provider = codex_extended_provider_without_proof(&baseline, gateway_url); + let provider_is_managed = codex_provider_item_is_managed(&baseline, gateway_url); + restore_codex_config_from_backup(&mut baseline, previous, provider_is_managed, false); + restore_codex_client_proof_from_backup(&mut baseline, previous, Some(challenge)); + if let Some(provider) = preserved_provider { + ensure_table(&mut baseline, "model_providers") + .insert("nemo-relay-openai", Item::Table(provider)); + } + remove_empty_table(&mut baseline, "model_providers"); + remove_empty_table(&mut baseline, "features"); + atomic_write_private(&backup_path(path), baseline.to_string().as_bytes()) +} + +fn read_codex_backup_doc_for_refresh(path: &Path) -> Result, String> { + let backup = backup_path(path); + let raw = match fs::read_to_string(&backup) { + Ok(raw) => raw, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => { + return Err(format!("failed to read {}: {error}", backup.display())); + } + }; + // A stale backup from an interrupted or older installation is not a trustworthy baseline. + // The enclosing install transaction still snapshots and restores its bytes on later failure. + Ok(raw.parse::().ok()) +} + +fn restore_codex_client_proof_from_backup( + doc: &mut DocumentMut, + backup: &DocumentMut, + challenge: Option<&BootstrapChallengeKey>, +) { + let current_is_managed = codex_provider_header(doc, BOOTSTRAP_CLIENT_TOKEN_HEADER) + .and_then(TomlValue::as_str) + .is_some_and(|token| { + challenge.is_some_and(|challenge| challenge.verify_client_token(token)) + }); + if !current_is_managed { + return; + } + let replacement = codex_provider_header(backup, BOOTSTRAP_CLIENT_TOKEN_HEADER) + .filter(|value| { + !value.as_str().is_some_and(|token| { + challenge.is_some_and(|challenge| challenge.verify_client_token(token)) + }) + }) + .cloned(); + let Some(provider) = doc + .get_mut("model_providers") + .and_then(Item::as_table_mut) + .and_then(|providers| providers.get_mut("nemo-relay-openai")) + .and_then(Item::as_table_mut) + else { + return; + }; + let Some(headers) = provider.get_mut("http_headers") else { + return; + }; + + let remove_headers = if let Some(headers) = headers.as_inline_table_mut() { + if !headers.contains_key(BOOTSTRAP_CLIENT_TOKEN_HEADER) { + return; + } + match replacement { + Some(value) => { + headers.insert(BOOTSTRAP_CLIENT_TOKEN_HEADER, value); + } + None => { + headers.remove(BOOTSTRAP_CLIENT_TOKEN_HEADER); + } + } + headers.is_empty() + } else if let Some(headers) = headers.as_table_mut() { + if !headers.contains_key(BOOTSTRAP_CLIENT_TOKEN_HEADER) { + return; + } + match replacement { + Some(value) => { + headers.insert(BOOTSTRAP_CLIENT_TOKEN_HEADER, Item::Value(value)); + } + None => { + headers.remove(BOOTSTRAP_CLIENT_TOKEN_HEADER); + } + } + headers.is_empty() + } else { + false + }; + if remove_headers { + provider.remove("http_headers"); + } +} + +/// Remove installer-owned state from a backup produced by an older partial-reinstall bug. +/// +/// The client proof is the ownership signal: only a token authenticated by this user's current +/// bootstrap key permits cleanup. Exact generated providers are removed wholesale. A provider +/// edited by the user is retained, with only Relay's reserved proof and hook feature removed. +fn sanitize_codex_backup_doc( + mut backup: DocumentMut, + gateway_url: &str, + challenge: Option<&BootstrapChallengeKey>, +) -> DocumentMut { + let reserved_token = + codex_provider_header(&backup, BOOTSTRAP_CLIENT_TOKEN_HEADER).and_then(TomlValue::as_str); + let has_managed_proof = reserved_token.is_some_and(|token| { + challenge.is_some_and(|challenge| challenge.verify_client_token(token)) + }); + let provider_is_managed = codex_provider_item_is_managed(&backup, gateway_url); + let has_generated_lineage = provider_is_managed && reserved_token.is_some(); + if !has_managed_proof && !has_generated_lineage { + return backup; + } + + if provider_is_managed { + let preserved_provider = codex_extended_provider_without_proof(&backup, gateway_url); + if top_level_item_is_str(&backup, "model_provider", "nemo-relay-openai") { + backup.as_table_mut().remove("model_provider"); + } + if let Some(providers) = backup + .get_mut("model_providers") + .and_then(Item::as_table_mut) + { + providers.remove("nemo-relay-openai"); + } + if let Some(provider) = preserved_provider { + ensure_table(&mut backup, "model_providers") + .insert("nemo-relay-openai", Item::Table(provider)); + } + } else { + let empty = DocumentMut::new(); + restore_codex_client_proof_from_backup(&mut backup, &empty, challenge); + } + remove_table_item_if_bool(&mut backup, "features", "hooks", true); + remove_empty_table(&mut backup, "model_providers"); + remove_empty_table(&mut backup, "features"); + backup +} + +/// Extract fields a user added to an otherwise generated provider table. +fn codex_provider_user_extensions(doc: &DocumentMut, gateway_url: &str) -> Option { + if !codex_provider_item_is_managed(doc, gateway_url) { + return None; + } + let mut extensions = doc + .get("model_providers")? + .as_table()? + .get("nemo-relay-openai")? + .as_table()? + .clone(); + for key in [ + "name", + "base_url", + "wire_api", + "requires_openai_auth", + "supports_websockets", + ] { + extensions.remove(key); + } + remove_codex_provider_header(&mut extensions, BOOTSTRAP_CLIENT_TOKEN_HEADER); + (!extensions.is_empty()).then_some(extensions) +} + +fn codex_extended_provider_without_proof(doc: &DocumentMut, gateway_url: &str) -> Option
{ + codex_provider_user_extensions(doc, gateway_url)?; + let mut provider = doc + .get("model_providers")? + .as_table()? + .get("nemo-relay-openai")? + .as_table()? + .clone(); + remove_codex_provider_header(&mut provider, BOOTSTRAP_CLIENT_TOKEN_HEADER); + Some(provider) +} + +fn remove_codex_provider_header(provider: &mut Table, name: &str) { + let Some(headers) = provider.get_mut("http_headers") else { + return; + }; + let remove_headers = if let Some(headers) = headers.as_inline_table_mut() { + headers.remove(name); + headers.is_empty() + } else if let Some(headers) = headers.as_table_mut() { + headers.remove(name); + headers.is_empty() + } else { + false + }; + if remove_headers { + provider.remove("http_headers"); + } +} + +fn merge_codex_provider_extensions(doc: &mut DocumentMut, extensions: &Table) { + let providers = ensure_table(doc, "model_providers"); + if !providers + .get("nemo-relay-openai") + .is_some_and(Item::is_table) + { + providers["nemo-relay-openai"] = Item::Table(Table::new()); + } + let provider = providers["nemo-relay-openai"] + .as_table_mut() + .expect("provider table was just inserted"); + for (key, item) in extensions.iter() { + if key == "http_headers" { + merge_codex_provider_headers(provider, item); + } else { + provider.insert(key, item.clone()); + } + } +} + +fn merge_codex_provider_headers(provider: &mut Table, extensions: &Item) { + let Some(entries) = codex_header_entries(extensions) else { + provider.insert("http_headers", extensions.clone()); + return; + }; + let Some(headers) = provider.get_mut("http_headers") else { + provider.insert("http_headers", extensions.clone()); + return; + }; + if let Some(headers) = headers.as_inline_table_mut() { + for (name, value) in entries { + headers.insert(&name, value); + } + } else if let Some(headers) = headers.as_table_mut() { + for (name, value) in entries { + headers.insert(&name, Item::Value(value)); + } + } else { + *headers = extensions.clone(); + } +} + +fn codex_header_entries(headers: &Item) -> Option> { + if let Some(headers) = headers.as_inline_table() { + return Some( + headers + .iter() + .map(|(name, value)| (name.to_string(), value.clone())) + .collect(), + ); + } + headers.as_table().and_then(|headers| { + headers + .iter() + .map(|(name, item)| { + item.as_value() + .cloned() + .map(|value| (name.to_string(), value)) + }) + .collect::>>() + }) +} + +fn codex_provider_has_only_generated_fields(doc: &DocumentMut) -> bool { + let Some(provider) = doc + .get("model_providers") + .and_then(Item::as_table) + .and_then(|providers| providers.get("nemo-relay-openai")) + .and_then(Item::as_table) + else { + return false; + }; + let generated_fields = [ + "name", + "base_url", + "wire_api", + "requires_openai_auth", + "supports_websockets", + "http_headers", + ]; + if provider.len() != generated_fields.len() + || !generated_fields + .iter() + .all(|field| provider.contains_key(field)) + { + return false; + } + let Some(headers) = provider.get("http_headers") else { + return false; + }; + headers.as_inline_table().is_some_and(|headers| { + headers.len() == 1 && headers.contains_key(BOOTSTRAP_CLIENT_TOKEN_HEADER) + }) || headers.as_table().is_some_and(|headers| { + headers.len() == 1 && headers.contains_key(BOOTSTRAP_CLIENT_TOKEN_HEADER) + }) +} + +pub(crate) fn codex_provider_header<'a>(doc: &'a DocumentMut, name: &str) -> Option<&'a TomlValue> { + let headers = doc + .get("model_providers") + .and_then(Item::as_table) + .and_then(|providers| providers.get("nemo-relay-openai")) + .and_then(Item::as_table) + .and_then(|provider| provider.get("http_headers"))?; + headers + .as_inline_table() + .and_then(|headers| { + headers + .iter() + .find_map(|(key, value)| key.eq_ignore_ascii_case(name).then_some(value)) + }) + .or_else(|| { + headers.as_table().and_then(|headers| { + headers.iter().find_map(|(key, item)| { + key.eq_ignore_ascii_case(name) + .then(|| item.as_value()) + .flatten() + }) + }) + }) +} + +pub(crate) fn read_optional_text(path: &Path) -> Result { + match fs::read_to_string(path) { + Ok(raw) => Ok(raw), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(String::new()), + Err(error) => Err(format!("failed to read {}: {error}", path.display())), + } +} + +pub(crate) fn uninstall_codex_config( + path: &Path, + gateway_url: &str, + preserve_hooks: bool, +) -> Result<(), String> { + if !path.exists() { + return Ok(()); + } + let raw = fs::read_to_string(path) + .map_err(|error| format!("failed to read {}: {error}", path.display()))?; + let mut doc = raw + .parse::() + .map_err(|error| format!("invalid TOML in {}: {error}", path.display()))?; + let challenge = BootstrapChallengeKey::load_existing().map_err(|error| error.to_string())?; + let backup_doc = read_codex_backup_doc(path)? + .map(|backup| sanitize_codex_backup_doc(backup, gateway_url, challenge.as_ref())); + let preserved_provider = codex_extended_provider_without_proof(&doc, gateway_url); + let provider_is_managed = codex_provider_item_is_managed(&doc, gateway_url); + match backup_doc.as_ref() { + Some(backup_doc) => { + restore_codex_config_from_backup( + &mut doc, + backup_doc, + provider_is_managed, + preserve_hooks, + ); + } + None => remove_codex_config_without_backup(&mut doc, provider_is_managed, preserve_hooks), + } + if let Some(provider) = preserved_provider { + ensure_table(&mut doc, "model_providers") + .insert("nemo-relay-openai", Item::Table(provider)); + } + let empty_backup = DocumentMut::new(); + restore_codex_client_proof_from_backup( + &mut doc, + backup_doc.as_ref().unwrap_or(&empty_backup), + challenge.as_ref(), + ); + + remove_empty_table(&mut doc, "model_providers"); + remove_empty_table(&mut doc, "features"); + atomic_write(path, doc.to_string().as_bytes())?; + remove_backup(path) +} + +fn read_codex_backup_doc(path: &Path) -> Result, String> { + let backup = backup_path(path); + if !backup.exists() { + return Ok(None); + } + let raw = fs::read_to_string(&backup) + .map_err(|error| format!("failed to read {}: {error}", backup.display()))?; + raw.parse::() + .map(Some) + .map_err(|error| format!("invalid TOML in {}: {error}", backup.display())) +} + +fn restore_codex_config_from_backup( + doc: &mut DocumentMut, + backup_doc: &DocumentMut, + provider_is_managed: bool, + preserve_hooks: bool, +) { + if provider_is_managed { + restore_top_level_item_if_str(doc, backup_doc, "model_provider", "nemo-relay-openai"); + restore_table_item(doc, backup_doc, "model_providers", "nemo-relay-openai"); + } + if !preserve_hooks || feature_hooks_enabled(doc) != Some(true) { + restore_table_item_if_bool(doc, backup_doc, "features", "hooks", true); + } +} + +fn remove_codex_config_without_backup( + doc: &mut DocumentMut, + provider_is_managed: bool, + preserve_hooks: bool, +) { + if !provider_is_managed { + return; + } + if top_level_item_is_str(doc, "model_provider", "nemo-relay-openai") { + doc.as_table_mut().remove("model_provider"); + } + if let Some(providers) = doc.get_mut("model_providers").and_then(Item::as_table_mut) { + providers.remove("nemo-relay-openai"); + } + if !preserve_hooks { + remove_table_item_if_bool(doc, "features", "hooks", true); + } +} + +pub(crate) fn remove_legacy_codex_hooks(path: &Path) -> Result<(), String> { + if !path.exists() { + return Ok(()); + } + let original = read_json_object(path)?; + let mut updated = original.clone(); + let relay = current_exe()?; + remove_managed_codex_hook_groups(&mut updated, &relay, None); + if updated == original { + return Ok(()); + } + backup(path)?; + write_json(path, &updated) +} + +#[cfg(test)] +pub(crate) fn install_codex_hooks(path: &Path, gateway_url: &str) -> Result<(), String> { + let relay = current_exe()?; + let command = codex_hook_command(gateway_url); + let generated = generated_hooks(CodingAgent::Codex, &command); + let mut existing = if path.exists() { + let raw = fs::read_to_string(path) + .map_err(|error| format!("failed to read {}: {error}", path.display()))?; + let existing = serde_json::from_str::(&raw) + .map_err(|error| format!("invalid JSON in {}: {error}", path.display()))?; + if !hook_config_contains_generated_groups(&existing, &generated) { + backup(path)?; + } + existing + } else { + json!({}) + }; + remove_managed_codex_hook_groups(&mut existing, &relay, Some(gateway_url)); + let merged = merge_hooks(existing, generated).map_err(|error| error.to_string())?; + let bytes = serde_json::to_vec_pretty(&merged).map_err(|error| error.to_string())?; + let mut output = bytes; + output.push(b'\n'); + atomic_write(path, &output) +} + +pub(crate) fn uninstall_codex_hooks(path: &Path, _gateway_url: &str) -> Result { + if !path.exists() { + return Ok(false); + } + let mut value = read_json_object(path)?; + let relay = current_exe()?; + remove_managed_codex_hook_groups(&mut value, &relay, None); + let has_remaining_hooks = hook_config_has_hook_groups(&value); + write_json(path, &value)?; + Ok(has_remaining_hooks) +} + +pub(crate) fn remove_managed_codex_hook_groups( + value: &mut Value, + relay: &Path, + keep_gateway_url: Option<&str>, +) { + let Some(hooks) = value.get_mut("hooks").and_then(Value::as_object_mut) else { + return; + }; + let events: Vec = hooks.keys().cloned().collect(); + for event in events { + let should_remove_event = hooks + .get_mut(&event) + .and_then(Value::as_array_mut) + .map(|groups| { + groups.retain_mut(|group| { + let Some(commands) = group.get_mut("hooks").and_then(Value::as_array_mut) + else { + return true; + }; + let before = commands.len(); + commands.retain(|hook| { + !managed_codex_hook_for_relay(hook, relay, keep_gateway_url) + }); + commands.len() == before || !commands.is_empty() + }); + groups.is_empty() + }) + .unwrap_or(false); + if should_remove_event { + hooks.remove(&event); + } + } +} + +fn managed_codex_hook_for_relay( + hook: &Value, + relay: &Path, + keep_gateway_url: Option<&str>, +) -> bool { + if hook.get("type").and_then(Value::as_str) != Some("command") { + return false; + } + let Some(command) = hook.get("command").and_then(Value::as_str) else { + return false; + }; + if keep_gateway_url.is_some_and(|gateway_url| command == codex_hook_command(gateway_url)) { + return false; + } + command == legacy_codex_hook_command(relay) + || command == legacy_named_codex_hook_command() + || legacy_relay_hook_command(command) +} + +fn legacy_relay_hook_command(command: &str) -> bool { + let Some((program, arguments)) = [" hook-forward codex", " plugin-shim hook codex"] + .into_iter() + .find_map(|separator| command.split_once(separator)) + else { + return false; + }; + if !arguments.is_empty() && !arguments.starts_with(" --gateway-url ") { + return false; + } + let executable = program + .rsplit(['/', '\\']) + .next() + .unwrap_or(program) + .trim_matches(['\'', '"']) + .to_ascii_lowercase(); + matches!(executable.as_str(), "nemo-relay" | "nemo-relay.exe") +} + +#[cfg(test)] +pub(crate) fn hook_config_contains_generated_groups(existing: &Value, generated: &Value) -> bool { + let Some(generated_hooks) = generated.get("hooks").and_then(Value::as_object) else { + return false; + }; + generated_hooks.iter().all(|(event, groups)| { + groups.as_array().is_some_and(|groups| { + groups + .iter() + .all(|group| generated_event_contains_group(existing, event, group)) + }) + }) +} + +#[cfg(test)] +pub(crate) fn generated_event_contains_group(config: &Value, event: &str, group: &Value) -> bool { + config + .get("hooks") + .and_then(Value::as_object) + .and_then(|hooks| hooks.get(event)) + .and_then(Value::as_array) + .is_some_and(|groups| groups.iter().any(|existing| existing == group)) +} + +pub(crate) fn hook_config_has_hook_groups(config: &Value) -> bool { + config + .get("hooks") + .and_then(Value::as_object) + .is_some_and(|hooks| { + hooks + .values() + .any(|groups| groups.as_array().is_some_and(|groups| !groups.is_empty())) + }) +} + +pub(crate) fn codex_config_doc_has_managed_install(doc: &DocumentMut, gateway_url: &str) -> bool { + doc.get("model_provider") + .and_then(Item::as_value) + .and_then(|value| value.as_str()) + == Some("nemo-relay-openai") + && codex_provider_item_is_managed(doc, gateway_url) + && feature_hooks_enabled(doc) == Some(true) +} + +#[cfg(test)] +pub(crate) fn codex_provider_gateway_url(path: &Path) -> Option { + let raw = fs::read_to_string(path).ok()?; + let doc = raw.parse::().ok()?; + doc.get("model_providers") + .and_then(Item::as_table) + .and_then(|providers| providers.get("nemo-relay-openai")) + .and_then(Item::as_table) + .and_then(|provider| provider.get("base_url")) + .and_then(Item::as_value) + .and_then(|value| value.as_str()) + .map(ToOwned::to_owned) +} + +pub(crate) fn restore_top_level_item(doc: &mut DocumentMut, backup: &DocumentMut, key: &str) { + if let Some(item) = backup.as_table().get(key).cloned() { + doc.as_table_mut().insert(key, item); + } else { + doc.as_table_mut().remove(key); + } +} + +pub(crate) fn restore_top_level_item_if_str( + doc: &mut DocumentMut, + backup: &DocumentMut, + key: &str, + expected: &str, +) { + if top_level_item_is_str(doc, key, expected) { + restore_top_level_item(doc, backup, key); + } +} + +fn top_level_item_is_str(doc: &DocumentMut, key: &str, expected: &str) -> bool { + doc.get(key) + .and_then(Item::as_value) + .and_then(|value| value.as_str()) + == Some(expected) +} + +pub(crate) fn restore_table_item( + doc: &mut DocumentMut, + backup: &DocumentMut, + table: &str, + key: &str, +) { + if let Some(item) = backup + .get(table) + .and_then(Item::as_table) + .and_then(|table| table.get(key)) + .cloned() + { + ensure_table(doc, table).insert(key, item); + } else if let Some(table) = doc.get_mut(table).and_then(Item::as_table_mut) { + table.remove(key); + } +} + +pub(crate) fn restore_table_item_if_bool( + doc: &mut DocumentMut, + backup: &DocumentMut, + table: &str, + key: &str, + expected: bool, +) { + let current = doc + .get(table) + .and_then(Item::as_table) + .and_then(|table| table.get(key)) + .and_then(Item::as_value) + .and_then(|value| value.as_bool()); + if current == Some(expected) { + restore_table_item(doc, backup, table, key); + } +} + +pub(crate) fn codex_provider_item_is_managed(doc: &DocumentMut, gateway_url: &str) -> bool { + doc.get("model_providers") + .and_then(Item::as_table) + .and_then(|providers| providers.get("nemo-relay-openai")) + .and_then(Item::as_table) + .is_some_and(|provider| codex_provider_table_is_managed_for_gateway(provider, gateway_url)) +} + +pub(crate) fn codex_provider_table_is_managed_for_gateway( + provider: &Table, + gateway_url: &str, +) -> bool { + provider + .get("name") + .and_then(Item::as_value) + .and_then(|value| value.as_str()) + == Some("NeMo Relay") + && provider + .get("base_url") + .and_then(Item::as_value) + .and_then(|value| value.as_str()) + == Some(gateway_url) + && provider + .get("wire_api") + .and_then(Item::as_value) + .and_then(|value| value.as_str()) + == Some("responses") + && provider + .get("requires_openai_auth") + .and_then(Item::as_value) + .and_then(|value| value.as_bool()) + == Some(true) + && provider + .get("supports_websockets") + .and_then(Item::as_value) + .and_then(|value| value.as_bool()) + == Some(false) +} + +pub(crate) fn feature_hooks_enabled(doc: &DocumentMut) -> Option { + doc.get("features") + .and_then(Item::as_table) + .and_then(|table| table.get("hooks")) + .and_then(Item::as_value) + .and_then(|value| value.as_bool()) +} + +pub(crate) fn remove_empty_table(doc: &mut DocumentMut, key: &str) { + let is_empty = doc + .get(key) + .and_then(Item::as_table) + .is_some_and(Table::is_empty); + if is_empty { + doc.as_table_mut().remove(key); + } +} + +pub(crate) fn remove_table_item_if_bool( + doc: &mut DocumentMut, + table: &str, + key: &str, + expected: bool, +) { + let should_remove = doc + .get(table) + .and_then(Item::as_table) + .and_then(|table| table.get(key)) + .and_then(Item::as_value) + .and_then(|value| value.as_bool()) + == Some(expected); + if should_remove && let Some(table) = doc.get_mut(table).and_then(Item::as_table_mut) { + table.remove(key); + } +} + +pub(crate) fn codex_provider_installed(gateway_url: &str) -> bool { + let Ok(path) = codex_home_dir().map(|home| home.join("config.toml")) else { + return false; + }; + let Ok(raw) = fs::read_to_string(path) else { + return false; + }; + let Ok(doc) = raw.parse::() else { + return false; + }; + let Ok(Some(key)) = BootstrapChallengeKey::load_existing() else { + return false; + }; + codex_config_doc_has_managed_install(&doc, gateway_url) + && codex_provider_client_token(&doc).is_some_and(|token| key.verify_client_token(token)) +} + +pub(crate) fn codex_provider_client_token(doc: &DocumentMut) -> Option<&str> { + codex_provider_header(doc, BOOTSTRAP_CLIENT_TOKEN_HEADER).and_then(TomlValue::as_str) +} + +pub(crate) fn codex_hooks_installed(path: &Path) -> Result { + codex_hooks_installed_with_generation(path, None) +} + +pub(crate) fn codex_hooks_installed_with_generation( + path: &Path, + generation_token: Option<&str>, +) -> Result { + let value = read_json_object(path)?; + let generated = generated_hooks( + CodingAgent::Codex, + &expected_plugin_hook_command_with_token(path, generation_token)?, + ); + Ok(value == generated) +} + +pub(crate) fn codex_home_dir() -> Result { + if let Some(path) = env::var_os("CODEX_HOME").filter(|path| !path.is_empty()) { + return Ok(PathBuf::from(path)); + } + Ok(home_dir()?.join(".codex")) +} + +pub(crate) fn codex_hook_command(gateway_url: &str) -> String { + format!( + "nemo-relay hook-forward codex --gateway-url {}", + shell_quote_arg_for_platform(gateway_url, cfg!(windows)) + ) +} + +pub(crate) fn codex_plugin_hook_command( + relay: &Path, + generation: &Path, + generation_token: &str, +) -> Result { + crate::hooks::persistent_hook_forward_command( + relay, + CodingAgent::Codex, + generation, + generation_token, + ) +} + +#[cfg(test)] +pub(crate) fn codex_plugin_hook_command_for_platform( + relay: &Path, + generation: &Path, + generation_token: &str, + windows: bool, +) -> String { + crate::hooks::persistent_hook_forward_command_for_platform( + relay, + CodingAgent::Codex, + generation, + generation_token, + windows, + ) +} + +pub(crate) fn legacy_codex_hook_command(relay: &Path) -> String { + format!("{} plugin-shim hook codex", shell_quote(relay)) +} + +pub(crate) fn legacy_named_codex_hook_command() -> &'static str { + "nemo-relay plugin-shim hook codex" +} diff --git a/crates/cli/src/agents/codex/install.rs b/crates/cli/src/agents/codex/install.rs new file mode 100644 index 000000000..c9ff485f7 --- /dev/null +++ b/crates/cli/src/agents/codex/install.rs @@ -0,0 +1,16 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use std::process::ExitCode; + +use crate::agents::CodingAgent; +use crate::error::CliError; +use crate::installation::{InstallRequest, UninstallRequest}; + +pub(crate) fn install(command: InstallRequest) -> Result { + crate::installation::marketplace::install(CodingAgent::Codex, command) +} + +pub(crate) fn uninstall(command: UninstallRequest) -> Result { + crate::installation::marketplace::uninstall(CodingAgent::Codex, command) +} diff --git a/crates/cli/src/agents/codex/launch.rs b/crates/cli/src/agents/codex/launch.rs new file mode 100644 index 000000000..0fcd0978b --- /dev/null +++ b/crates/cli/src/agents/codex/launch.rs @@ -0,0 +1,231 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use std::path::PathBuf; + +use serde_json::Value; + +use crate::agents::CodingAgent; +use crate::configuration::{RELAY_PLUGIN_ID, RELAY_SOURCE_PLUGIN_ID}; +use crate::error::CliError; +use crate::hooks::{generated_hooks, transparent_hook_forward_command}; +use crate::process::{PreparedAgentLaunch, insert_after_host}; + +pub(crate) fn prepare(launch: &mut PreparedAgentLaunch, gateway_url: &str) -> Result<(), CliError> { + let has_openai_key = std::env::var("OPENAI_API_KEY") + .ok() + .is_some_and(|value| !value.is_empty()); + let has_codex_auth = std::env::var_os("HOME") + .or_else(|| std::env::var_os("USERPROFILE")) + .map(|home| PathBuf::from(home).join(".codex/auth.json").exists()) + .unwrap_or(false); + if !has_openai_key && !has_codex_auth { + eprintln!( + "warning: No OpenAI credentials found. Either export OPENAI_API_KEY \ + (e.g. `export OPENAI_API_KEY=sk-...`), log in to codex (`codex --login`), \ + or pass `--openai-base-url` to an upstream that needs no key." + ); + } + let hook_command = transparent_hook_forward_command( + &transparent_hook_executable(), + CodingAgent::Codex, + gateway_url, + ) + .map_err(CliError::Launch)?; + let hook_groups = generated_hooks(CodingAgent::Codex, &hook_command); + let mut args = vec![ + "--config".to_string(), + "features.hooks=true".to_string(), + "--config".to_string(), + "model_provider=\"nemo-relay-openai\"".to_string(), + "--config".to_string(), + gateway_provider_config(gateway_url), + ]; + for (event, groups) in hook_groups["hooks"].as_object().into_iter().flatten() { + args.push("--config".to_string()); + args.push(format!("hooks.{event}={}", hook_groups_toml(groups))); + } + args.push("--config".to_string()); + args.push(session_hook_state_override(&hook_groups)?); + insert_after_host(&mut launch.argv, launch.host_index, args); + Ok(()) +} + +pub(crate) fn session_hook_state_override(generated: &Value) -> Result { + let events = generated + .get("hooks") + .and_then(Value::as_object) + .ok_or_else(|| CliError::Launch("generated Codex hooks were malformed".into()))?; + let mut states = Vec::new(); + for (event, groups) in events { + let groups = groups.as_array().ok_or_else(|| { + CliError::Launch(format!( + "generated Codex {event} hook groups were malformed" + )) + })?; + let event_key = hook_event_key(event); + for (group_index, group) in groups.iter().enumerate() { + let group = group.as_object().ok_or_else(|| { + CliError::Launch(format!("generated Codex {event} hook group was malformed")) + })?; + let handlers = group + .get("hooks") + .and_then(Value::as_array) + .ok_or_else(|| { + CliError::Launch(format!( + "generated Codex {event} hook handlers were malformed" + )) + })?; + for (handler_index, handler) in handlers.iter().enumerate() { + let hash = command_hook_hash(&event_key, group, handler)?; + let key = format!( + "//config.toml:{event_key}:{group_index}:{handler_index}" + ); + states.push(format!( + "{}={{trusted_hash={},enabled=true}}", + toml_string(&key), + toml_string(&hash) + )); + for plugin_id in [RELAY_PLUGIN_ID, RELAY_SOURCE_PLUGIN_ID] { + let key = format!( + "{plugin_id}:hooks/hooks.json:{event_key}:{group_index}:{handler_index}" + ); + states.push(format!("{}={{enabled=false}}", toml_string(&key))); + } + } + } + } + Ok(format!("hooks.state={{{}}}", states.join(","))) +} + +fn hook_event_key(event: &str) -> String { + let mut normalized = String::with_capacity(event.len() + 2); + for (index, character) in event.chars().enumerate() { + if character.is_ascii_uppercase() { + if index > 0 { + normalized.push('_'); + } + normalized.push(character.to_ascii_lowercase()); + } else { + normalized.push(character); + } + } + normalized +} + +pub(crate) fn command_hook_hash( + event_key: &str, + group: &serde_json::Map, + handler: &Value, +) -> Result { + use sha2::{Digest, Sha256}; + + let handler = handler.as_object().ok_or_else(|| { + CliError::Launch(format!( + "generated Codex {event_key} command hook was malformed" + )) + })?; + if handler.get("type").and_then(Value::as_str) != Some("command") { + return Err(CliError::Launch(format!( + "generated Codex {event_key} hook was not a command" + ))); + } + let command = handler + .get(if cfg!(windows) { + "commandWindows" + } else { + "command" + }) + .or_else(|| handler.get("command")) + .and_then(Value::as_str) + .ok_or_else(|| { + CliError::Launch(format!( + "generated Codex {event_key} hook command was missing" + )) + })?; + let timeout = handler + .get("timeout") + .and_then(Value::as_u64) + .unwrap_or(600) + .max(1); + let mut normalized_handler = serde_json::Map::new(); + normalized_handler.insert("type".into(), Value::String("command".into())); + normalized_handler.insert("command".into(), Value::String(command.into())); + normalized_handler.insert("timeout".into(), Value::Number(timeout.into())); + normalized_handler.insert("async".into(), Value::Bool(false)); + if let Some(status) = handler.get("statusMessage").and_then(Value::as_str) { + normalized_handler.insert("statusMessage".into(), Value::String(status.into())); + } + let mut identity = serde_json::Map::new(); + identity.insert("event_name".into(), Value::String(event_key.into())); + if let Some(matcher) = group.get("matcher").and_then(Value::as_str) { + identity.insert("matcher".into(), Value::String(matcher.into())); + } + identity.insert( + "hooks".into(), + Value::Array(vec![Value::Object(normalized_handler)]), + ); + let bytes = serde_json::to_vec(&canonical_json(Value::Object(identity))) + .map_err(|error| CliError::Launch(format!("failed to hash Codex hook: {error}")))?; + let digest = Sha256::digest(bytes); + Ok(format!( + "sha256:{}", + digest + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::() + )) +} + +fn canonical_json(value: Value) -> Value { + match value { + Value::Object(object) => { + let mut entries = object.into_iter().collect::>(); + entries.sort_by(|left, right| left.0.cmp(&right.0)); + Value::Object( + entries + .into_iter() + .map(|(key, value)| (key, canonical_json(value))) + .collect(), + ) + } + Value::Array(values) => Value::Array(values.into_iter().map(canonical_json).collect()), + other => other, + } +} + +fn gateway_provider_config(gateway_url: &str) -> String { + format!( + "model_providers.nemo-relay-openai={{name=\"NeMo Relay OpenAI\",base_url={},wire_api=\"responses\",requires_openai_auth=true,supports_websockets=false}}", + toml_string(gateway_url) + ) +} + +pub(crate) fn hook_groups_toml(value: &Value) -> String { + let mut groups = Vec::new(); + for group in value.as_array().into_iter().flatten() { + let matcher = group + .get("matcher") + .and_then(Value::as_str) + .map(|matcher| format!("matcher={},", toml_string(matcher))) + .unwrap_or_default(); + let command = group["hooks"][0]["command"].as_str().unwrap_or_default(); + groups.push(format!( + "{{{matcher}hooks=[{{type=\"command\",command={},timeout=30}}]}}", + toml_string(command) + )); + } + format!("[{}]", groups.join(",")) +} + +pub(crate) fn toml_string(value: &str) -> String { + let escaped = value.replace('\\', "\\\\").replace('"', "\\\""); + format!("\"{escaped}\"") +} + +fn transparent_hook_executable() -> PathBuf { + std::env::current_exe() + .map(|path| path.canonicalize().unwrap_or(path)) + .map(crate::agents::portable_executable_path) + .unwrap_or_else(|_| PathBuf::from("nemo-relay")) +} diff --git a/crates/cli/src/agents/codex/mod.rs b/crates/cli/src/agents/codex/mod.rs new file mode 100644 index 000000000..c3db8c5a5 --- /dev/null +++ b/crates/cli/src/agents/codex/mod.rs @@ -0,0 +1,40 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use semver::Version; + +use super::AgentDescriptor; + +pub(super) mod app_server; +pub(super) mod assets; +pub(crate) mod doctor; +pub(super) mod host; +pub(crate) mod install; +pub(crate) mod launch; + +pub(super) const DESCRIPTOR: AgentDescriptor = AgentDescriptor { + argument: "codex", + install_argument: "codex", + label: "Codex", + executable: "codex", + hook_path: "/hooks/codex", + version_product: "codex-cli", + minimum_version: (0, 143, 0), + hook_events: &[ + "SessionStart", + "UserPromptSubmit", + "PreToolUse", + "PostToolUse", + "PermissionRequest", + "SubagentStart", + "SubagentStop", + "Stop", + "PreCompact", + "PostCompact", + ], + direct_hook_entries: false, +}; + +pub(super) fn parse_version(raw: &str) -> Option { + Version::parse(raw.strip_prefix("codex-cli ")?).ok() +} diff --git a/crates/cli/src/adapters/hermes.rs b/crates/cli/src/agents/hermes/adapter.rs similarity index 96% rename from crates/cli/src/adapters/hermes.rs rename to crates/cli/src/agents/hermes/adapter.rs index 582107c99..6465d4513 100644 --- a/crates/cli/src/adapters/hermes.rs +++ b/crates/cli/src/agents/hermes/adapter.rs @@ -4,12 +4,12 @@ use axum::http::HeaderMap; use serde_json::{Map, Value, json}; -use crate::adapters::{ +use crate::agents::shared::adapters::{ AdapterOutcome, ClassificationRules, HERMES_PAYLOAD_EXTRACTOR, classify, common_session_event, event_name, metadata, normalize_name, session_id, }; -use crate::json_path::value_at; -use crate::model::{AgentKind, LlmEvent, NormalizedEvent}; +use crate::events::json_path::value_at; +use crate::events::{AgentKind, LlmEvent, NormalizedEvent}; /// Normalizes Hermes shell hook payloads without emitting control directives. /// @@ -21,17 +21,15 @@ pub(crate) fn adapt(payload: Value, headers: &HeaderMap) -> AdapterOutcome { let normalized = normalize_name(&event_name); if normalized == "preapirequest" { return AdapterOutcome { - events: vec![crate::model::NormalizedEvent::LlmStarted(hermes_llm_event( - &payload, - headers, - &event_name, - ))], + events: vec![crate::events::NormalizedEvent::LlmStarted( + hermes_llm_event(&payload, headers, &event_name), + )], response: json!({}), }; } if normalized == "postapirequest" { return AdapterOutcome { - events: vec![crate::model::NormalizedEvent::LlmEnded(hermes_llm_event( + events: vec![crate::events::NormalizedEvent::LlmEnded(hermes_llm_event( &payload, headers, &event_name, @@ -41,7 +39,7 @@ pub(crate) fn adapt(payload: Value, headers: &HeaderMap) -> AdapterOutcome { } if normalized == "apirequesterror" { return AdapterOutcome { - events: vec![crate::model::NormalizedEvent::LlmEnded(hermes_llm_event( + events: vec![crate::events::NormalizedEvent::LlmEnded(hermes_llm_event( &payload, headers, &event_name, diff --git a/crates/cli/src/alignment/hermes.rs b/crates/cli/src/agents/hermes/alignment.rs similarity index 97% rename from crates/cli/src/alignment/hermes.rs rename to crates/cli/src/agents/hermes/alignment.rs index 5aecc558c..85e514bff 100644 --- a/crates/cli/src/alignment/hermes.rs +++ b/crates/cli/src/agents/hermes/alignment.rs @@ -10,8 +10,10 @@ use serde_json::{Map, Value, json}; -use crate::alignment::{SessionAlias, insert_optional, json_string_at, merge_metadata}; -use crate::model::{AgentKind, SessionEvent, SubagentEvent}; +use crate::agents::shared::alignment::{ + SessionAlias, insert_optional, json_string_at, merge_metadata, +}; +use crate::events::{AgentKind, SessionEvent, SubagentEvent}; #[derive(Debug, Clone)] pub(crate) struct SubagentContext { diff --git a/crates/cli/src/agents/hermes/config.rs b/crates/cli/src/agents/hermes/config.rs new file mode 100644 index 000000000..f15c13efa --- /dev/null +++ b/crates/cli/src/agents/hermes/config.rs @@ -0,0 +1,334 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Pure Hermes YAML generation, migration, and ownership recognition. + +use std::path::{Path, PathBuf}; + +use serde_json::{Map, Value, json}; + +use crate::error::CliError; +use crate::hooks::{generated_hooks, merge_hooks}; + +pub(super) use crate::mcp::SERVER_NAME as MCP_SERVER_NAME; + +pub(super) fn user_config_path_with_override( + default_home: &Path, + hermes_home: Option, +) -> PathBuf { + hermes_home + .filter(|value| !value.is_empty()) + .map(PathBuf::from) + .unwrap_or_else(|| default_home.join(".hermes")) + .join("config.yaml") +} + +/// Rewrites the Relay-owned portion of a Hermes config for a transparent run. The fixed MCP +/// client is removed because the wrapper already owns a dynamic gateway. +pub(crate) fn transparent_config( + existing: &str, + relay: &Path, + gateway_url: &str, +) -> Result { + let mut root = parse_yaml_object(Some(existing), "Hermes config")?; + let owned = owned_install_command(&root, relay, None)?; + strip_owned_hooks(&mut root, owned.as_deref())?; + remove_owned_mcp(&mut root, owned.is_some())?; + let command = crate::hooks::transparent_hook_forward_command( + relay, + crate::agents::CodingAgent::Hermes, + gateway_url, + ) + .map_err(CliError::Install)?; + let mut root = merge_hooks( + root, + generated_hooks(crate::agents::CodingAgent::Hermes, &command), + )?; + let object = root + .as_object_mut() + .ok_or_else(|| CliError::Launch("Hermes config must be a YAML mapping".into()))?; + let mut model = match object.remove("model") { + Some(Value::Object(model)) => model, + Some(Value::String(default)) => { + Map::from_iter([("default".into(), Value::String(default))]) + } + Some(Value::Null) | None => Map::new(), + Some(_) => { + return Err(CliError::Launch( + "Hermes model config must be a string or mapping".into(), + )); + } + }; + model.insert("provider".into(), Value::String("custom".into())); + model.insert( + "base_url".into(), + Value::String(format!("{}/v1", gateway_url.trim_end_matches('/'))), + ); + object.insert("model".into(), Value::Object(model)); + serde_yaml::to_string(&root).map_err(|error| CliError::Install(error.to_string())) +} + +pub(crate) fn persistent_hook_command( + relay: &Path, + generation: &Path, + generation_token: &str, +) -> Result { + crate::hooks::persistent_hook_forward_command( + relay, + crate::agents::CodingAgent::Hermes, + generation, + generation_token, + ) +} + +#[cfg(test)] +pub(super) fn persistent_hook_command_for_platform( + relay: &Path, + generation: &Path, + generation_token: &str, + windows: bool, +) -> String { + crate::hooks::persistent_hook_forward_command_for_platform( + relay, + crate::agents::CodingAgent::Hermes, + generation, + generation_token, + windows, + ) +} + +pub(super) fn persistent_config( + existing: Option<&str>, + relay: &Path, + command: &str, + generation: &Path, + generation_token: &str, + environment: &[String], +) -> Result { + let mut root = parse_yaml_object(existing, "Hermes config")?; + let owned = owned_install_command(&root, relay, Some(generation))?; + if root + .pointer(&format!("/mcp_servers/{MCP_SERVER_NAME}")) + .is_some() + && owned.is_none() + { + return Err(CliError::Install(format!( + "Hermes MCP server `{MCP_SERVER_NAME}` already exists and is not managed by Relay; rename or remove it before installing the Relay integration" + ))); + } + strip_owned_hooks(&mut root, owned.as_deref())?; + root = merge_hooks( + root, + generated_hooks(crate::agents::CodingAgent::Hermes, command), + )?; + let servers = object_field_mut(&mut root, "mcp_servers", "mcp_servers")?; + servers.insert( + MCP_SERVER_NAME.into(), + expected_mcp_server(relay, generation, generation_token, environment), + ); + Ok(root) +} + +pub(super) fn expected_mcp_server( + relay: &Path, + generation: &Path, + generation_token: &str, + environment: &[String], +) -> Value { + let mut server = crate::mcp::persistent_server(relay, generation, generation_token); + let forwarded = server + .get_mut("env") + .and_then(Value::as_object_mut) + .expect("persistent MCP server environment is an object"); + for name in environment { + forwarded.insert(name.clone(), json!(format!("${{{name}}}"))); + } + server +} + +pub(super) fn forwarded_environment_names( + environment: &[String], + plugin_config: Option<&Value>, +) -> Vec { + crate::mcp_environment::forwarded_names(environment.iter().cloned(), plugin_config) +} + +pub(super) fn strip_owned_hooks( + root: &mut Value, + owned_command: Option<&str>, +) -> Result<(), CliError> { + let Some(hooks) = root.get_mut("hooks") else { + return Ok(()); + }; + let remove_hooks = { + let hooks = hooks + .as_object_mut() + .ok_or_else(|| CliError::Install("Hermes hooks must be an object".into()))?; + let mut empty = Vec::new(); + for (event, groups) in hooks.iter_mut() { + let groups = groups.as_array_mut().ok_or_else(|| { + CliError::Install(format!("Hermes {event} hooks must be an array")) + })?; + groups.retain(|group| { + group + .get("command") + .and_then(Value::as_str) + .is_none_or(|command| Some(command) != owned_command) + }); + if groups.is_empty() { + empty.push(event.clone()); + } + } + for event in empty { + hooks.remove(&event); + } + hooks.is_empty() + }; + if remove_hooks { + root.as_object_mut() + .expect("Hermes config root checked as object") + .remove("hooks"); + } + Ok(()) +} + +pub(super) fn remove_owned_mcp(root: &mut Value, owned: bool) -> Result<(), CliError> { + let Some(servers) = root.get_mut("mcp_servers") else { + return Ok(()); + }; + let servers = servers + .as_object_mut() + .ok_or_else(|| CliError::Install("Hermes mcp_servers must be an object".into()))?; + if owned { + servers.remove(MCP_SERVER_NAME); + } + if servers.is_empty() { + root.as_object_mut() + .expect("Hermes config root checked as object") + .remove("mcp_servers"); + } + Ok(()) +} + +pub(super) fn owned_install_command( + root: &Value, + relay: &Path, + expected_generation: Option<&Path>, +) -> Result, CliError> { + let Some(server) = root.pointer(&format!("/mcp_servers/{MCP_SERVER_NAME}")) else { + return Ok(None); + }; + if server.get("command") != Some(&json!(relay)) { + return Ok(None); + } + let env = server.get("env").and_then(Value::as_object); + if server.get("args") == Some(&json!(["mcp"])) + && env.and_then(|env| env.get("NEMO_RELAY_GATEWAY_BIND")) + == Some(&json!(crate::bootstrap::DEFAULT_BIND)) + { + let generation = env + .and_then(|env| env.get(crate::installation::generation::GENERATION_FILE_ENV)) + .and_then(Value::as_str); + let token = env + .and_then(|env| env.get(crate::installation::generation::GENERATION_TOKEN_ENV)) + .and_then(Value::as_str); + if let (Some(generation), Some(token)) = (generation, token) + && !token.is_empty() + && expected_generation.is_none_or(|expected| Path::new(generation) == expected) + { + let command = persistent_hook_command(relay, Path::new(generation), token) + .map_err(CliError::Install)?; + return Ok(Some(command)); + } + } + legacy_owned_command(root, relay) +} + +fn legacy_owned_command(root: &Value, relay: &Path) -> Result, CliError> { + let server = &root["mcp_servers"][MCP_SERVER_NAME]; + if server.get("args") != Some(&json!(["mcp", "--agent", "hermes"])) { + return Ok(None); + } + let Some(hooks) = root.get("hooks").and_then(Value::as_object) else { + return Ok(None); + }; + let mut common = None; + for event in crate::agents::CodingAgent::Hermes.hook_events() { + let commands = hooks + .get(*event) + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(|entry| entry.get("command").and_then(Value::as_str)) + .filter(|command| legacy_command_uses_relay(command, relay)) + .collect::>(); + if commands.len() != 1 || common.is_some_and(|value| value != commands[0]) { + return Ok(None); + } + common = Some(commands[0]); + } + Ok(common.map(str::to_owned)) +} + +fn legacy_command_uses_relay(command: &str, relay: &Path) -> bool { + let relay = relay.to_string_lossy(); + let quoted = crate::agents::shell_quote_arg_for_platform(&relay, cfg!(windows)); + [relay.as_ref(), quoted.as_str()].into_iter().any(|prefix| { + command.strip_prefix(prefix).is_some_and(|arguments| { + [" hook-forward hermes", " plugin-shim hook hermes"] + .iter() + .any(|marker| arguments.starts_with(marker)) + }) + }) +} + +pub(super) fn relay_is_executable(path: &Path) -> bool { + if !path.is_file() { + return false; + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::metadata(path) + .map(|metadata| metadata.permissions().mode() & 0o111 != 0) + .unwrap_or(false) + } + #[cfg(not(unix))] + { + true + } +} + +pub(super) fn parse_yaml_object(raw: Option<&str>, description: &str) -> Result { + let value = match raw.filter(|raw| !raw.trim().is_empty()) { + Some(raw) => serde_yaml::from_str(raw) + .map_err(|error| CliError::Install(format!("invalid {description}: {error}")))?, + None => json!({}), + }; + if value.is_object() { + Ok(value) + } else { + Err(CliError::Install(format!( + "{description} must contain an object" + ))) + } +} + +pub(super) fn yaml_bytes(value: &Value) -> Result, CliError> { + serde_yaml::to_string(value) + .map(String::into_bytes) + .map_err(|error| CliError::Install(error.to_string())) +} + +fn object_field_mut<'a>( + root: &'a mut Value, + field: &str, + description: &str, +) -> Result<&'a mut Map, CliError> { + root.as_object_mut() + .expect("config root checked as object") + .entry(field) + .or_insert_with(|| json!({})) + .as_object_mut() + .ok_or_else(|| CliError::Install(format!("Hermes {description} must be an object"))) +} diff --git a/crates/cli/src/agents/hermes/doctor.rs b/crates/cli/src/agents/hermes/doctor.rs new file mode 100644 index 000000000..0f8b19509 --- /dev/null +++ b/crates/cli/src/agents/hermes/doctor.rs @@ -0,0 +1,13 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use std::path::Path; + +pub(crate) fn hook_status(hooks_path: Option<&Path>) -> Result { + match hooks_path { + Some(path) => super::diagnose_persistent(path).map_err(|error| { + format!("persistent MCP/hooks: {error}; run `nemo-relay install hermes --force`") + }), + None => Ok("hooks: injected through an isolated HERMES_HOME during run".into()), + } +} diff --git a/crates/cli/src/agents/hermes/files.rs b/crates/cli/src/agents/hermes/files.rs new file mode 100644 index 000000000..7cbdfd2c5 --- /dev/null +++ b/crates/cli/src/agents/hermes/files.rs @@ -0,0 +1,207 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Serialized, rollback-capable filesystem operations for the Hermes integration. + +use std::fs::{self, File, OpenOptions}; +use std::path::{Path, PathBuf}; +use std::thread; +use std::time::{Duration, Instant}; + +use crate::error::CliError; +use crate::filesystem::{LockAttempt, try_lock_exclusive}; +use crate::installation::generation::GENERATION_FILE_NAME; + +const ALLOWLIST_FILE_NAME: &str = "shell-hooks-allowlist.json"; +const INSTALL_LOCK_FILE_NAME: &str = ".nemo-relay-operation.lock"; +const INSTALL_LOCK_RETRY: Duration = Duration::from_millis(25); +pub(super) const INSTALL_LOCK_TIMEOUT: Duration = Duration::from_secs(5); + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) struct PersistentPaths { + pub(super) config: PathBuf, + pub(super) allowlist: PathBuf, + pub(super) generation: PathBuf, +} + +impl PersistentPaths { + pub(super) fn for_config(config: PathBuf) -> Result { + let home = config.parent().ok_or_else(|| { + CliError::Install(format!( + "Hermes config path {} has no parent directory", + config.display() + )) + })?; + Ok(Self { + allowlist: home.join(ALLOWLIST_FILE_NAME), + generation: home.join(GENERATION_FILE_NAME), + config, + }) + } + + pub(super) fn all(&self) -> [PathBuf; 3] { + [ + self.config.clone(), + self.allowlist.clone(), + self.generation.clone(), + ] + } +} + +pub(super) fn acquire_install_lock(config: &Path, timeout: Duration) -> Result { + let home = config.parent().ok_or_else(|| { + format!( + "Hermes config path {} has no parent directory", + config.display() + ) + })?; + acquire_lock_file( + &home.join(INSTALL_LOCK_FILE_NAME), + timeout, + "another Hermes integration update", + ) +} + +/// Uses Hermes's own sibling allowlist lock so Relay cannot lose an unrelated approval that +/// Hermes records concurrently. +pub(super) fn acquire_allowlist_lock(allowlist: &Path, timeout: Duration) -> Result { + let mut lock = allowlist.as_os_str().to_os_string(); + lock.push(".lock"); + acquire_lock_file( + &PathBuf::from(lock), + timeout, + "a Hermes shell-hook approval update", + ) +} + +fn acquire_lock_file(path: &Path, timeout: Duration, contention: &str) -> Result { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .map_err(|error| format!("failed to create {}: {error}", parent.display()))?; + } + let mut options = OpenOptions::new(); + options.create(true).truncate(false).read(true).write(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let file = options.open(path).map_err(|error| { + format!( + "failed to open Hermes install lock {}: {error}", + path.display() + ) + })?; + let deadline = Instant::now() + timeout; + loop { + match try_lock_exclusive(&file) { + Ok(LockAttempt::Acquired) => return Ok(file), + Ok(LockAttempt::Contended) if Instant::now() < deadline => { + thread::sleep( + INSTALL_LOCK_RETRY.min(deadline.saturating_duration_since(Instant::now())), + ); + } + Ok(LockAttempt::Contended) => { + return Err(format!( + "timed out waiting for {contention} at {}; wait for it to finish and retry", + path.display() + )); + } + Err(error) => { + return Err(format!( + "failed to lock Hermes integration state {}: {error}", + path.display() + )); + } + } + } +} + +pub(super) fn read_optional_utf8(path: &Path) -> Result, CliError> { + match fs::read_to_string(path) { + Ok(raw) => Ok(Some(raw)), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(CliError::Install(format!( + "failed to read {}: {error}", + path.display() + ))), + } +} + +pub(super) fn replace_optional_file( + path: &Path, + bytes: Option<&[u8]>, + write: &mut W, +) -> Result<(), String> +where + W: FnMut(&Path, &[u8]) -> Result<(), String>, +{ + match bytes { + Some(bytes) => write(path, bytes), + None => remove_optional_file(path), + } +} + +pub(super) fn remove_optional_file(path: &Path) -> Result<(), String> { + match fs::remove_file(path) { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(format!("failed to remove {}: {error}", path.display())), + } +} + +pub(super) struct FileSnapshot { + path: PathBuf, + bytes: Option>, + permissions: Option, +} + +impl FileSnapshot { + pub(super) fn capture(path: &Path) -> Result { + match fs::read(path) { + Ok(bytes) => { + let permissions = fs::metadata(path) + .map(|metadata| metadata.permissions()) + .map_err(|error| { + CliError::Install(format!( + "failed to snapshot permissions on {}: {error}", + path.display() + )) + })?; + Ok(Self { + path: path.to_path_buf(), + bytes: Some(bytes), + permissions: Some(permissions), + }) + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Self { + path: path.to_path_buf(), + bytes: None, + permissions: None, + }), + Err(error) => Err(CliError::Install(format!( + "failed to snapshot {}: {error}", + path.display() + ))), + } + } + + pub(super) fn restore(&self, write: &mut W) -> Result<(), String> + where + W: FnMut(&Path, &[u8]) -> Result<(), String>, + { + if let Some(bytes) = self.bytes.as_deref() { + write(&self.path, bytes)?; + if let Some(permissions) = self.permissions.as_ref() { + fs::set_permissions(&self.path, permissions.clone()).map_err(|error| { + format!( + "failed to restore permissions on {}: {error}", + self.path.display() + ) + })?; + } + return Ok(()); + } + remove_optional_file(&self.path) + } +} diff --git a/crates/cli/src/agents/hermes/install.rs b/crates/cli/src/agents/hermes/install.rs new file mode 100644 index 000000000..d656dab3f --- /dev/null +++ b/crates/cli/src/agents/hermes/install.rs @@ -0,0 +1,191 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use std::path::PathBuf; +use std::process::ExitCode; + +use crate::agents::CodingAgent; +use crate::error::CliError; +use crate::installation::marketplace::HostPluginReadiness; +use crate::installation::marketplace::host::{ + CommandRunner, RealCommandRunner, require_host_cli, require_relay, validate_host_version, + validate_relay_hook_forward, validate_relay_mcp, +}; +use crate::installation::marketplace::state::PluginInstallOptions; +use crate::installation::{InstallRequest, UninstallRequest}; + +pub(crate) fn install(command: InstallRequest) -> Result { + let options = options(command.dry_run, command.skip_doctor, command.force); + let runner = RealCommandRunner; + require_host_cli(CodingAgent::Hermes, &options, &runner).map_err(CliError::Install)?; + validate_host_version(CodingAgent::Hermes, &options, &runner).map_err(CliError::Install)?; + let relay = require_relay(&options, &runner).map_err(CliError::Install)?; + validate_relay_hook_forward(&relay, &options, &runner).map_err(CliError::Install)?; + validate_relay_mcp(&relay, &options, &runner).map_err(CliError::Install)?; + let config = config_path().map_err(CliError::Install)?; + if options.dry_run { + println!("configure Hermes MCP and hooks at {}", config.display()); + return Ok(ExitCode::SUCCESS); + } + super::install_persistent(&config, &relay) + .map_err(|error| CliError::Install(error.to_string()))?; + if !options.skip_doctor { + super::diagnose_persistent(&config).map_err(CliError::Install)?; + } + println!("installed Hermes integration"); + Ok(ExitCode::SUCCESS) +} + +pub(crate) fn uninstall(command: UninstallRequest) -> Result { + let config = config_path().map_err(CliError::Install)?; + if command.dry_run { + println!( + "remove Relay-owned Hermes MCP and hooks from {}", + config.display() + ); + return Ok(ExitCode::SUCCESS); + } + super::uninstall_persistent(&config).map_err(|error| CliError::Install(error.to_string()))?; + println!("uninstalled Hermes integration"); + Ok(ExitCode::SUCCESS) +} + +pub(crate) fn config_path() -> Result { + std::env::var_os("HOME") + .or_else(|| std::env::var_os("USERPROFILE")) + .map(PathBuf::from) + .map(|home| super::user_config_path(&home)) + .ok_or_else(|| "cannot determine home directory (set HOME or USERPROFILE)".into()) +} + +fn options(dry_run: bool, skip_doctor: bool, force: bool) -> PluginInstallOptions { + PluginInstallOptions { + install_dir: PathBuf::new(), + operation_lock_dir: PathBuf::new(), + force, + dry_run, + skip_doctor, + } +} + +pub(crate) fn doctor( + options: &PluginInstallOptions, + runner: &dyn CommandRunner, +) -> Result<(), String> { + let report = doctor_json_value(options, runner)?; + for check in report["readiness_checks"] + .as_array() + .expect("Hermes readiness checks are an array") + { + println!( + "{}: {} ({})", + check["name"].as_str().unwrap_or_default(), + if check["ok"] == serde_json::json!(true) { + "ok" + } else { + "failed" + }, + check["details"].as_str().unwrap_or_default() + ); + } + (report["ok"] == serde_json::json!(true)) + .then_some(()) + .ok_or_else(|| { + format!( + "Hermes integration doctor checks failed; remediation: {}", + report["remediation"].as_str().unwrap_or_default() + ) + }) +} + +pub(crate) fn doctor_json_value( + options: &PluginInstallOptions, + runner: &dyn CommandRunner, +) -> Result { + let config = config_path()?; + let readiness = collect_readiness(&config, options, runner); + Ok(serde_json::json!({ + "ok": readiness.ok(), + "host": readiness.host, + "remediation": readiness.remediation, + "config": config, + "readiness_checks": readiness.checks + })) +} + +pub(crate) fn collect_readiness( + config: &std::path::Path, + options: &PluginInstallOptions, + runner: &dyn CommandRunner, +) -> HostPluginReadiness { + let mut readiness = HostPluginReadiness { + host: CodingAgent::Hermes.install_arg().into(), + remediation: format!( + "nemo-relay install {} --force", + CodingAgent::Hermes.install_arg() + ), + state_path: config.to_path_buf(), + marketplace: None, + plugin: None, + checks: Vec::new(), + relay: None, + host_plugin_registered: None, + host_marketplace_registered: None, + plugin_setup: None, + }; + + let host_cli = require_host_cli(CodingAgent::Hermes, options, runner); + readiness.push( + "Host CLI", + host_cli + .as_ref() + .map(|_| "hermes is available".into()) + .map_err(Clone::clone), + ); + let version = validate_host_version(CodingAgent::Hermes, options, runner); + if version.is_err() { + readiness.remediation = format!( + "upgrade to {}, then run `nemo-relay install {} --force`", + CodingAgent::Hermes.version_requirement(), + CodingAgent::Hermes.install_arg() + ); + } + readiness.push( + "Hermes Agent version", + version.map(|_| format!("{} is installed", CodingAgent::Hermes.version_requirement())), + ); + + let relay = super::configured_relay_executable(config); + readiness.push( + "Configured Relay binary", + relay + .as_ref() + .map(|path| format!("found at {}", path.display())) + .map_err(Clone::clone), + ); + match relay { + Ok(relay) => { + readiness.relay = Some(relay.clone()); + readiness.push( + "Relay hook support", + validate_relay_hook_forward(&relay, options, runner) + .map(|_| "hook-forward is supported".into()), + ); + readiness.push( + "Relay MCP support", + validate_relay_mcp(&relay, options, runner) + .map(|_| "native mcp subcommand is supported".into()), + ); + } + Err(error) => { + let unavailable = || format!("cannot verify configured Relay capabilities: {error}"); + readiness.push("Relay hook support", Err(unavailable())); + readiness.push("Relay MCP support", Err(unavailable())); + } + } + readiness.push( + "Hermes MCP, hooks, and trust", + super::diagnose_persistent(config), + ); + readiness +} diff --git a/crates/cli/src/agents/hermes/integration.rs b/crates/cli/src/agents/hermes/integration.rs new file mode 100644 index 000000000..f14457a3f --- /dev/null +++ b/crates/cli/src/agents/hermes/integration.rs @@ -0,0 +1,608 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Hermes-owned MCP and lifecycle-hook configuration. + +use std::env; +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::SystemTime; + +use serde_json::{Map, Value, json}; + +#[cfg(test)] +use super::config::persistent_hook_command_for_platform; +use super::config::{ + MCP_SERVER_NAME, expected_mcp_server, forwarded_environment_names, owned_install_command, + parse_yaml_object, persistent_config, relay_is_executable, remove_owned_mcp, strip_owned_hooks, + user_config_path_with_override, yaml_bytes, +}; +pub(crate) use super::config::{persistent_hook_command, transparent_config}; +use super::files::{ + FileSnapshot, INSTALL_LOCK_TIMEOUT, PersistentPaths, acquire_allowlist_lock, + acquire_install_lock, read_optional_utf8, remove_optional_file, replace_optional_file, +}; +use super::trust::{json_bytes, parse_json_object, trusted_hooks, verify_trust}; +use crate::agents::CodingAgent; +use crate::bootstrap::DEFAULT_BIND; +use crate::error::CliError; +use crate::filesystem::atomic_write; +#[cfg(test)] +use crate::installation::generation::GENERATION_FILE_NAME; +use crate::installation::generation::{ + GENERATION_FILE_ENV, GENERATION_TOKEN_ENV, GenerationRetirement, InstallGeneration, +}; + +/// Hermes host configuration is user-owned even when Relay itself uses project configuration. +/// Project-specific Relay behavior remains available through transparent `nemo-relay run`. +pub(crate) fn user_config_path(default_home: &Path) -> PathBuf { + user_config_path_with_override(default_home, env::var_os("HERMES_HOME")) +} + +pub(crate) fn install_persistent(config: &Path, relay: &Path) -> Result, CliError> { + let relay = relay.canonicalize().unwrap_or_else(|_| relay.to_path_buf()); + let relay = crate::agents::portable_executable_path(relay); + if !relay_is_executable(&relay) { + return Err(CliError::Install(format!( + "nemo-relay executable is missing or not executable at {}", + relay.display() + ))); + } + let paths = PersistentPaths::for_config(config.to_path_buf())?; + let _lock = + acquire_install_lock(&paths.config, INSTALL_LOCK_TIMEOUT).map_err(CliError::Install)?; + let _allowlist_lock = acquire_allowlist_lock(&paths.allowlist, INSTALL_LOCK_TIMEOUT) + .map_err(CliError::Install)?; + let plugin_config = crate::configuration::user_plugin_runtime_config()?; + let environment = env::vars_os() + .filter_map(|(name, _)| name.into_string().ok()) + .collect::>(); + let mut retirement = retire_generation_before_gateway_stop(&paths)?; + let result = install_persistent_with_generation( + paths, + &relay, + &environment, + plugin_config.as_ref(), + retirement.as_ref(), + SystemTime::now(), + atomic_write, + ); + finish_generation_mutation(result, retirement.as_mut(), "install") +} + +pub(crate) fn persistent_state_exists(config: &Path) -> bool { + PersistentPaths::for_config(config.to_path_buf()) + .ok() + .and_then(|paths| persistent_paths_have_managed_state(&paths).ok()) + .unwrap_or(false) +} + +pub(crate) fn uninstall_persistent(config: &Path) -> Result, CliError> { + let paths = PersistentPaths::for_config(config.to_path_buf())?; + if !persistent_paths_have_managed_state(&paths)? { + return Ok(Vec::new()); + } + let _lock = + acquire_install_lock(&paths.config, INSTALL_LOCK_TIMEOUT).map_err(CliError::Install)?; + let _allowlist_lock = acquire_allowlist_lock(&paths.allowlist, INSTALL_LOCK_TIMEOUT) + .map_err(CliError::Install)?; + if !persistent_paths_have_managed_state(&paths)? { + return Ok(Vec::new()); + } + let mut retirement = retire_generation_before_gateway_stop(&paths)?; + let result = uninstall_persistent_with(paths, atomic_write); + finish_generation_mutation(result, retirement.as_mut(), "uninstall") +} + +fn retire_generation_before_gateway_stop( + paths: &PersistentPaths, +) -> Result, CliError> { + let mut retirement = + GenerationRetirement::acquire(&paths.generation).map_err(CliError::Install)?; + if let Some(retirement) = retirement.as_mut() { + retirement + .invalidate_for_replacement() + .map_err(CliError::Install)?; + } + if let Err(error) = crate::agents::stop_plugin_gateway() { + if let Some(retirement) = retirement.as_mut() + && let Err(restore_error) = retirement.restore_after_rollback() + { + return Err(CliError::Install(format!( + "{error}; additionally failed to restore the Hermes MCP generation: {restore_error}" + ))); + } + return Err(CliError::Install(error)); + } + Ok(retirement) +} + +fn finish_generation_mutation( + result: Result, + retirement: Option<&mut GenerationRetirement>, + operation: &str, +) -> Result { + match result { + Ok(value) => { + if let Some(retirement) = retirement { + retirement.commit_replacement(); + } + Ok(value) + } + Err(error) => { + let Some(retirement) = retirement else { + return Err(error); + }; + match retirement.restore_after_rollback() { + Ok(()) => Err(error), + Err(restore_error) => Err(CliError::Install(format!( + "{error}; additionally failed to restore the Hermes MCP generation after {operation}: {restore_error}" + ))), + } + } + } +} + +fn persistent_paths_have_managed_state(paths: &PersistentPaths) -> Result { + if paths.generation.exists() { + return Ok(true); + } + if let Some(raw) = read_optional_utf8(&paths.config)? { + let config = parse_yaml_object(Some(&raw), "Hermes config")?; + if config_has_managed_state(&config) { + return Ok(true); + } + } + if let Some(raw) = read_optional_utf8(&paths.allowlist)? { + let allowlist = parse_json_object(Some(&raw), "Hermes shell-hook allowlist")?; + if allowlist_has_owned_command(&allowlist, None) { + return Ok(true); + } + } + Ok(false) +} + +fn config_has_managed_state(config: &Value) -> bool { + owned_command_from_config(config, None).is_some() +} + +fn allowlist_has_owned_command(allowlist: &Value, command: Option<&str>) -> bool { + allowlist + .get("approvals") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(|entry| entry.get("command").and_then(Value::as_str)) + .any(|candidate| { + command == Some(candidate) + || (command.is_none() && is_persistent_relay_hook_command(candidate)) + }) +} + +fn is_persistent_relay_hook_command(command: &str) -> bool { + #[cfg(any(windows, test))] + if let Some(arguments) = crate::hooks::decode_windows_hook_command(command) { + return matches!( + arguments.as_slice(), + [ + _, + hook_forward, + agent, + gateway_flag, + gateway_url, + generation_file_flag, + _, + generation_token_flag, + generation_token, + ] if hook_forward == "hook-forward" + && agent == "hermes" + && gateway_flag == "--gateway-url" + && gateway_url == crate::bootstrap::DEFAULT_URL + && generation_file_flag == "--generation-file" + && generation_token_flag == "--generation-token" + && !generation_token.is_empty() + ); + } + command.contains("hook-forward") + && command.contains("hermes") + && command.contains("--gateway-url") + && command.contains(crate::bootstrap::DEFAULT_URL) + && command.contains("--generation-file") + && command.contains("--generation-token") +} + +fn owned_command_from_config(config: &Value, generation: Option<&Path>) -> Option { + let relay = config + .pointer(&format!("/mcp_servers/{MCP_SERVER_NAME}/command")) + .and_then(Value::as_str) + .map(PathBuf::from)?; + owned_install_command(config, &relay, generation) + .ok() + .flatten() +} + +pub(crate) fn diagnose_persistent(config_path: &Path) -> Result { + let paths = PersistentPaths::for_config(config_path.to_path_buf()) + .map_err(|error| error.to_string())?; + let raw = fs::read_to_string(&paths.config) + .map_err(|error| format!("failed to read {}: {error}", paths.config.display()))?; + let config = parse_yaml_object(Some(&raw), "Hermes config").map_err(|e| e.to_string())?; + let relay = relay_executable_from_config(&config)?; + if !relay_is_executable(&relay) { + return Err(format!( + "configured nemo-relay executable is missing or not executable at {}", + relay.display() + )); + } + let generation = InstallGeneration::capture(paths.generation.clone())?; + let command = persistent_hook_command(&relay, &paths.generation, generation.token())?; + verify_hook_definitions(&config, &command)?; + verify_trust(&paths.allowlist, &command)?; + + let mcp_env = config["mcp_servers"][MCP_SERVER_NAME] + .get("env") + .and_then(Value::as_object) + .ok_or_else(|| "Hermes Relay MCP environment is missing".to_string())?; + if mcp_env.get("NEMO_RELAY_GATEWAY_BIND") != Some(&json!(DEFAULT_BIND)) { + return Err(format!( + "Hermes Relay MCP must use the shared gateway bind {DEFAULT_BIND}" + )); + } + let configured_generation = mcp_env + .get(GENERATION_FILE_ENV) + .and_then(Value::as_str) + .ok_or_else(|| "Hermes Relay MCP generation fence is missing".to_string())?; + if Path::new(configured_generation) != paths.generation { + return Err("Hermes Relay MCP generation fence points at the wrong file".into()); + } + let configured_token = mcp_env + .get(GENERATION_TOKEN_ENV) + .and_then(Value::as_str) + .ok_or_else(|| "Hermes Relay MCP expected generation identity is missing".to_string())?; + if configured_token != generation.token() { + return Err("Hermes Relay MCP expected generation identity is stale".into()); + } + + let plugin_config = + crate::configuration::user_plugin_runtime_config().map_err(|e| e.to_string())?; + let environment = env::vars_os() + .filter_map(|(name, _)| name.into_string().ok()) + .collect::>(); + let environment = forwarded_environment_names(&environment, plugin_config.as_ref()); + let expected = expected_mcp_server(&relay, &paths.generation, generation.token(), &environment); + let expected_env = expected + .get("env") + .and_then(Value::as_object) + .expect("expected MCP environment is an object"); + let missing = environment + .into_iter() + .filter(|name| mcp_env.get(name) != expected_env.get(name)) + .collect::>(); + if !missing.is_empty() { + return Err(format!( + "Hermes Relay MCP is missing environment names {}; run `nemo-relay install hermes --force`", + missing.join(", ") + )); + } + Ok(format!( + "MCP lifecycle and {} hooks trusted at {}", + CodingAgent::Hermes.hook_events().len(), + paths.config.display() + )) +} + +/// Returns the exact Relay binary configured for Hermes's managed MCP client. +/// +/// Doctor uses this path instead of the currently running binary so it verifies the executable +/// that Hermes will actually launch. +pub(crate) fn configured_relay_executable(config_path: &Path) -> Result { + let raw = fs::read_to_string(config_path) + .map_err(|error| format!("failed to read {}: {error}", config_path.display()))?; + let config = parse_yaml_object(Some(&raw), "Hermes config").map_err(|e| e.to_string())?; + let relay = relay_executable_from_config(&config)?; + if !relay_is_executable(&relay) { + return Err(format!( + "configured nemo-relay executable is missing or not executable at {}", + relay.display() + )); + } + Ok(relay) +} + +fn relay_executable_from_config(config: &Value) -> Result { + let server = config + .get("mcp_servers") + .and_then(|servers| servers.get(MCP_SERVER_NAME)) + .ok_or_else(|| format!("Hermes MCP server `{MCP_SERVER_NAME}` is missing"))?; + let relay = PathBuf::from( + server + .get("command") + .and_then(Value::as_str) + .ok_or_else(|| "Hermes Relay MCP command is missing".to_string())?, + ); + if owned_install_command(config, &relay, None) + .map_err(|error| error.to_string())? + .is_none() + { + return Err(format!( + "Hermes MCP server `{MCP_SERVER_NAME}` is not a managed Relay MCP client" + )); + } + Ok(relay) +} + +#[cfg(test)] +fn install_persistent_with( + paths: PersistentPaths, + relay: &Path, + environment: &[String], + plugin_config: Option<&Value>, + now: SystemTime, + write: W, +) -> Result, CliError> +where + W: FnMut(&Path, &[u8]) -> Result<(), String>, +{ + install_persistent_with_generation(paths, relay, environment, plugin_config, None, now, write) +} + +fn install_persistent_with_generation( + paths: PersistentPaths, + relay: &Path, + environment: &[String], + plugin_config: Option<&Value>, + generation_transaction: Option<&GenerationRetirement>, + now: SystemTime, + mut write: W, +) -> Result, CliError> +where + W: FnMut(&Path, &[u8]) -> Result<(), String>, +{ + let snapshots = paths + .all() + .iter() + .map(|path| FileSnapshot::capture(path)) + .collect::, _>>()?; + let existing_config = read_optional_utf8(&paths.config)?; + let existing_allowlist = read_optional_utf8(&paths.allowlist)?; + let previous_command = match existing_config.as_deref() { + Some(raw) => { + let root = parse_yaml_object(Some(raw), "Hermes config")?; + owned_install_command(&root, relay, Some(&paths.generation))? + } + None => None, + }; + let environment = forwarded_environment_names(environment, plugin_config); + let token = uuid::Uuid::now_v7().to_string(); + let command = + persistent_hook_command(relay, &paths.generation, &token).map_err(CliError::Install)?; + let config = persistent_config( + existing_config.as_deref(), + relay, + &command, + &paths.generation, + &token, + &environment, + )?; + let allowlist = trusted_hooks( + existing_allowlist.as_deref(), + previous_command.as_deref(), + &command, + relay, + now, + )?; + let config = yaml_bytes(&config)?; + let allowlist = json_bytes(&allowlist)?; + let generation = format!("{token}\n").into_bytes(); + + let result = (|| { + // Trust is published before config so Hermes never observes a configured hook without + // its exact approval. The config write is the transaction's commit point. + write(&paths.generation, &generation)?; + write(&paths.allowlist, &allowlist)?; + write(&paths.config, &config)?; + verify_install( + &paths, + relay, + &command, + &environment, + &token, + generation_transaction, + ) + })(); + if let Err(error) = result { + return rollback_error("install", error, &snapshots, &mut write); + } + Ok(paths.all().into_iter().collect()) +} + +fn uninstall_persistent_with( + paths: PersistentPaths, + mut write: W, +) -> Result, CliError> +where + W: FnMut(&Path, &[u8]) -> Result<(), String>, +{ + let affected = paths + .all() + .into_iter() + .filter(|path| path.exists()) + .collect::>(); + let snapshots = paths + .all() + .iter() + .map(|path| FileSnapshot::capture(path)) + .collect::, _>>()?; + let config = read_optional_utf8(&paths.config)? + .map(|raw| { + let mut root = parse_yaml_object(Some(&raw), "Hermes config")?; + let owned = owned_command_from_config(&root, Some(&paths.generation)); + strip_owned_hooks(&mut root, owned.as_deref())?; + remove_owned_mcp(&mut root, owned.is_some())?; + if root.as_object().is_some_and(Map::is_empty) { + Ok(None) + } else { + yaml_bytes(&root).map(Some) + } + }) + .transpose()? + .flatten(); + let owned = read_optional_utf8(&paths.config)? + .and_then(|raw| parse_yaml_object(Some(&raw), "Hermes config").ok()) + .and_then(|root| owned_command_from_config(&root, Some(&paths.generation))); + let allowlist = read_optional_utf8(&paths.allowlist)? + .map(|raw| { + let mut root = parse_json_object(Some(&raw), "Hermes shell-hook allowlist")?; + let object = root + .as_object_mut() + .expect("allowlist root checked as object"); + if let Some(approvals) = object.get_mut("approvals") { + let approvals = approvals.as_array_mut().ok_or_else(|| { + CliError::Install( + "Hermes shell-hook allowlist approvals must be an array".into(), + ) + })?; + approvals.retain(|entry| { + entry + .get("command") + .and_then(Value::as_str) + .is_none_or(|command| Some(command) != owned.as_deref()) + }); + if approvals.is_empty() { + object.remove("approvals"); + } + } + if object.is_empty() { + Ok(None) + } else { + json_bytes(&root).map(Some) + } + }) + .transpose()? + .flatten(); + + let result = (|| { + remove_optional_file(&paths.generation)?; + replace_optional_file(&paths.allowlist, allowlist.as_deref(), &mut write)?; + replace_optional_file(&paths.config, config.as_deref(), &mut write)?; + verify_uninstall(&paths, owned.as_deref()) + })(); + if let Err(error) = result { + return rollback_error("uninstall", error, &snapshots, &mut write); + } + Ok(affected) +} + +fn rollback_error( + operation: &str, + error: String, + snapshots: &[FileSnapshot], + write: &mut W, +) -> Result +where + W: FnMut(&Path, &[u8]) -> Result<(), String>, +{ + let rollback_errors = snapshots + .iter() + .rev() + .filter_map(|snapshot| snapshot.restore(write).err()) + .collect::>(); + let rollback = if rollback_errors.is_empty() { + String::new() + } else { + format!("; rollback also failed: {}", rollback_errors.join("; ")) + }; + Err(CliError::Install(format!( + "failed to {operation} Hermes MCP integration: {error}{rollback}" + ))) +} + +fn verify_install( + paths: &PersistentPaths, + relay: &Path, + command: &str, + environment: &[String], + token: &str, + generation_transaction: Option<&GenerationRetirement>, +) -> Result<(), String> { + let raw = fs::read_to_string(&paths.config) + .map_err(|error| format!("failed to verify {}: {error}", paths.config.display()))?; + let config = parse_yaml_object(Some(&raw), "Hermes config").map_err(|e| e.to_string())?; + let expected = expected_mcp_server(relay, &paths.generation, token, environment); + if config.pointer("/mcp_servers/nemo-relay") != Some(&expected) { + return Err("Hermes MCP server did not persist exactly".into()); + } + verify_hook_definitions(&config, command)?; + verify_trust(&paths.allowlist, command)?; + + let actual_token = match generation_transaction { + Some(transaction) => transaction.active_visible_token()?, + None => InstallGeneration::capture(paths.generation.clone())? + .token() + .to_owned(), + }; + if actual_token != token { + return Err("Hermes MCP generation did not persist exactly".into()); + } + Ok(()) +} + +fn verify_hook_definitions(config: &Value, command: &str) -> Result<(), String> { + for event in CodingAgent::Hermes.hook_events() { + let groups = config + .pointer(&format!("/hooks/{event}")) + .and_then(Value::as_array) + .ok_or_else(|| format!("Hermes hook {event} is missing"))?; + let matching = groups + .iter() + .filter(|group| group.get("command").and_then(Value::as_str) == Some(command)) + .count(); + if matching != 1 { + return Err(format!( + "Hermes hook {event} expected exactly one trusted Relay handler" + )); + } + } + for (event, groups) in config + .get("hooks") + .and_then(Value::as_object) + .into_iter() + .flat_map(Map::iter) + { + let groups = groups + .as_array() + .ok_or_else(|| format!("Hermes {event} hooks must be an array"))?; + if !CodingAgent::Hermes.hook_events().contains(&event.as_str()) + && groups + .iter() + .any(|group| group.get("command").and_then(Value::as_str) == Some(command)) + { + return Err("Hermes config contains an unexpected Relay hook handler".into()); + } + } + Ok(()) +} + +fn verify_uninstall(paths: &PersistentPaths, owned_command: Option<&str>) -> Result<(), String> { + if paths.generation.exists() { + return Err("Hermes MCP generation fence still exists".into()); + } + if let Some(raw) = read_optional_utf8(&paths.config).map_err(|error| error.to_string())? { + let config = parse_yaml_object(Some(&raw), "Hermes config").map_err(|e| e.to_string())?; + if config_has_managed_state(&config) { + return Err("managed Hermes Relay config still exists".into()); + } + } + if let Some(raw) = read_optional_utf8(&paths.allowlist).map_err(|error| error.to_string())? { + let allowlist = parse_json_object(Some(&raw), "Hermes shell-hook allowlist") + .map_err(|e| e.to_string())?; + if allowlist_has_owned_command(&allowlist, owned_command) { + return Err("managed Hermes Relay trust approval still exists".into()); + } + } + Ok(()) +} + +#[cfg(test)] +#[path = "../../../tests/coverage/agents/hermes_tests.rs"] +mod tests; diff --git a/crates/cli/src/agents/hermes/launch.rs b/crates/cli/src/agents/hermes/launch.rs new file mode 100644 index 000000000..14cebb6a2 --- /dev/null +++ b/crates/cli/src/agents/hermes/launch.rs @@ -0,0 +1,179 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use std::path::{Path, PathBuf}; + +use crate::error::CliError; +use crate::process::PreparedAgentLaunch; + +pub(crate) fn prepare( + launch: &mut PreparedAgentLaunch, + hooks_path: Option<&Path>, + dry_run: bool, +) -> Result<(), CliError> { + let source_config = hooks_path_for_launch(hooks_path)?; + let gateway_url = launch + .env + .iter() + .find_map(|(name, value)| { + (name == crate::configuration::GATEWAY_URL_ENV).then_some(value.as_str()) + }) + .expect("transparent runs always define their gateway URL") + .to_owned(); + launch.env.push(("HERMES_ACCEPT_HOOKS".into(), "1".into())); + launch.env.push(( + "OPENAI_BASE_URL".into(), + format!("{}/v1", gateway_url.trim_end_matches('/')), + )); + if dry_run { + launch.notes.push(format!( + "would create an isolated Hermes config overlay for {}", + source_config.display() + )); + return Ok(()); + } + let source_home = source_config.parent().ok_or_else(|| { + CliError::Launch(format!( + "Hermes config path {} has no parent directory", + source_config.display() + )) + })?; + let overlay_home = create_overlay(source_home, &source_config, &gateway_url)?; + launch + .env + .push(("HERMES_HOME".into(), overlay_home.display().to_string())); + launch.notes.push(format!( + "using an isolated Hermes config overlay for {}", + source_config.display() + )); + launch.temp_dirs.push(overlay_home); + Ok(()) +} + +fn create_overlay( + source_home: &Path, + source_config: &Path, + gateway_url: &str, +) -> Result { + let overlay = source_home + .parent() + .filter(|parent| parent.is_dir()) + .and_then(|parent| { + crate::filesystem::temp::private_temp_dir(parent, ".nemo-relay-hermes-home").ok() + }) + .map(Ok) + .unwrap_or_else(|| { + crate::filesystem::temp::private_system_temp_dir("nemo-relay-hermes-home") + })?; + if let Err(error) = populate_overlay(&overlay, source_home, source_config, gateway_url) { + let _ = std::fs::remove_dir_all(&overlay); + return Err(error); + } + Ok(overlay) +} + +pub(crate) fn populate_overlay( + overlay: &Path, + source_home: &Path, + source_config: &Path, + gateway_url: &str, +) -> Result<(), CliError> { + let absolute_overlay = overlay + .canonicalize() + .unwrap_or_else(|_| overlay.to_path_buf()); + match std::fs::read_dir(source_home) { + Ok(entries) => { + for entry in entries { + let entry = entry?; + let name = entry.file_name(); + if name == "config.yaml" || name == "shell-hooks-allowlist.json" { + continue; + } + let source = entry.path(); + let absolute_source = source.canonicalize().unwrap_or_else(|_| source.clone()); + if absolute_overlay.starts_with(absolute_source) { + continue; + } + link_state(&source, &overlay.join(name), entry.file_type()?.is_dir())?; + } + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(CliError::Io(error)), + } + let existing = match std::fs::read_to_string(source_config) { + Ok(raw) => raw, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(), + Err(error) => return Err(CliError::Io(error)), + }; + let relay = std::env::current_exe() + .map(|path| path.canonicalize().unwrap_or(path)) + .map(crate::agents::portable_executable_path) + .unwrap_or_else(|_| PathBuf::from("nemo-relay")); + let contents = crate::agents::hermes::transparent_config(&existing, &relay, gateway_url)?; + std::fs::write(overlay.join("config.yaml"), contents)?; + Ok(()) +} + +fn link_state(source: &Path, destination: &Path, directory: bool) -> Result<(), CliError> { + #[cfg(unix)] + { + let _ = directory; + std::os::unix::fs::symlink(source, destination)?; + Ok(()) + } + #[cfg(windows)] + { + if directory { + create_windows_junction(source, destination)?; + } else if std::fs::hard_link(source, destination).is_err() { + std::fs::copy(source, destination)?; + } + Ok(()) + } + #[cfg(not(any(unix, windows)))] + { + let _ = directory; + std::fs::copy(source, destination)?; + Ok(()) + } +} + +#[cfg(windows)] +fn create_windows_junction(source: &Path, destination: &Path) -> Result<(), CliError> { + use std::os::windows::process::CommandExt; + + let mut command = std::process::Command::new( + std::env::var_os("COMSPEC").unwrap_or_else(|| std::ffi::OsString::from("cmd.exe")), + ); + command.args(["/d", "/e:on", "/v:off", "/s", "/c"]); + command + .raw_arg(r#""mklink /J "%NEMO_RELAY_JUNCTION_DEST%" "%NEMO_RELAY_JUNCTION_SOURCE%" >nul""#); + let status = command + .env("NEMO_RELAY_JUNCTION_SOURCE", source) + .env("NEMO_RELAY_JUNCTION_DEST", destination) + .status()?; + if status.success() { + Ok(()) + } else { + Err(CliError::Launch(format!( + "failed to create Hermes state junction {} -> {}: {status}", + destination.display(), + source.display() + ))) + } +} + +pub(crate) fn hooks_path_for_launch(configured: Option<&Path>) -> Result { + if let Some(path) = configured { + return Ok(path.to_path_buf()); + } + if let Some(home) = std::env::var_os("HERMES_HOME").filter(|value| !value.is_empty()) { + return Ok(PathBuf::from(home).join("config.yaml")); + } + let home = std::env::var_os("HOME") + .or_else(|| std::env::var_os("USERPROFILE")) + .ok_or_else(|| { + CliError::Launch("could not resolve home directory for Hermes hooks".into()) + })?; + Ok(PathBuf::from(home).join(".hermes").join("config.yaml")) +} diff --git a/crates/cli/src/agents/hermes/mod.rs b/crates/cli/src/agents/hermes/mod.rs new file mode 100644 index 000000000..3b6577dc7 --- /dev/null +++ b/crates/cli/src/agents/hermes/mod.rs @@ -0,0 +1,51 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use semver::Version; + +use super::AgentDescriptor; + +pub(super) const DESCRIPTOR: AgentDescriptor = AgentDescriptor { + argument: "hermes", + install_argument: "hermes", + label: "Hermes Agent", + executable: "hermes", + hook_path: "/hooks/hermes", + version_product: "Hermes Agent", + minimum_version: (0, 18, 2), + hook_events: &[ + "on_session_start", + "on_session_end", + "on_session_finalize", + "on_session_reset", + "pre_llm_call", + "post_llm_call", + "pre_api_request", + "post_api_request", + "api_request_error", + "pre_tool_call", + "post_tool_call", + "subagent_start", + "subagent_stop", + ], + direct_hook_entries: true, +}; + +pub(super) fn parse_version(raw: &str) -> Option { + Version::parse( + raw.strip_prefix("Hermes Agent v")? + .split_whitespace() + .next()?, + ) + .ok() +} + +mod config; +pub(crate) mod doctor; +mod files; +pub(crate) mod install; +mod integration; +pub(crate) mod launch; +mod trust; + +pub(crate) use integration::*; diff --git a/crates/cli/src/agents/hermes/trust.rs b/crates/cli/src/agents/hermes/trust.rs new file mode 100644 index 000000000..494c26c53 --- /dev/null +++ b/crates/cli/src/agents/hermes/trust.rs @@ -0,0 +1,117 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Exact Hermes shell-hook trust generation and verification. + +use std::fs; +use std::path::Path; +use std::time::SystemTime; + +use chrono::{DateTime, SecondsFormat, Utc}; +use serde_json::{Value, json}; + +use crate::agents::CodingAgent; +use crate::error::CliError; + +pub(super) fn trusted_hooks( + existing: Option<&str>, + previous_command: Option<&str>, + command: &str, + relay: &Path, + now: SystemTime, +) -> Result { + let mut root = parse_json_object(existing, "Hermes shell-hook allowlist")?; + let approvals = root + .as_object_mut() + .expect("JSON root checked as object") + .entry("approvals") + .or_insert_with(|| json!([])) + .as_array_mut() + .ok_or_else(|| { + CliError::Install("Hermes shell-hook allowlist approvals must be an array".into()) + })?; + approvals.retain(|entry| { + entry + .get("command") + .and_then(Value::as_str) + .is_none_or(|candidate| Some(candidate) != previous_command) + }); + let approved_at = timestamp(now); + let script_mtime_at_approval = fs::metadata(relay) + .and_then(|metadata| metadata.modified()) + .ok() + .map(timestamp); + approvals.extend(CodingAgent::Hermes.hook_events().iter().map(|event| { + json!({ + "event": event, + "command": command, + "approved_at": approved_at, + "script_mtime_at_approval": script_mtime_at_approval, + }) + })); + Ok(root) +} + +fn timestamp(time: SystemTime) -> String { + DateTime::::from(time).to_rfc3339_opts(SecondsFormat::Micros, true) +} + +pub(super) fn verify_trust(allowlist_path: &Path, command: &str) -> Result<(), String> { + let raw = fs::read_to_string(allowlist_path) + .map_err(|error| format!("failed to read {}: {error}", allowlist_path.display()))?; + let allowlist = + parse_json_object(Some(&raw), "Hermes shell-hook allowlist").map_err(|e| e.to_string())?; + let approvals = allowlist + .get("approvals") + .and_then(Value::as_array) + .ok_or_else(|| "Hermes shell-hook approvals are missing".to_string())?; + for event in CodingAgent::Hermes.hook_events() { + let matching = approvals + .iter() + .filter(|entry| { + entry.get("event").and_then(Value::as_str) == Some(event) + && entry.get("command").and_then(Value::as_str) == Some(command) + }) + .count(); + if matching != 1 { + return Err(format!( + "Hermes hook {event} expected exactly one trust approval" + )); + } + } + for entry in approvals { + if entry.get("command").and_then(Value::as_str) != Some(command) { + continue; + } + let event = entry + .get("event") + .and_then(Value::as_str) + .ok_or_else(|| "Hermes Relay hook approval is missing its event".to_string())?; + if !CodingAgent::Hermes.hook_events().contains(&event) { + return Err("Hermes allowlist contains an unexpected Relay hook approval".into()); + } + } + Ok(()) +} + +pub(super) fn parse_json_object(raw: Option<&str>, description: &str) -> Result { + let value = match raw.filter(|raw| !raw.trim().is_empty()) { + Some(raw) => serde_json::from_str(raw) + .map_err(|error| CliError::Install(format!("invalid {description}: {error}")))?, + None => json!({}), + }; + if value.is_object() { + Ok(value) + } else { + Err(CliError::Install(format!( + "{description} must contain a JSON object" + ))) + } +} + +pub(super) fn json_bytes(value: &Value) -> Result, CliError> { + let mut bytes = + serde_json::to_vec_pretty(value).map_err(|error| CliError::Install(error.to_string()))?; + bytes.push(b'\n'); + Ok(bytes) +} diff --git a/crates/cli/src/agents/mod.rs b/crates/cli/src/agents/mod.rs new file mode 100644 index 000000000..f8a769ee8 --- /dev/null +++ b/crates/cli/src/agents/mod.rs @@ -0,0 +1,960 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Canonical coding-agent identity and compatibility policy. + +pub(crate) mod claude; +pub(crate) mod codex; +pub(crate) mod hermes; +pub(crate) mod shared; + +use semver::Version; + +/// Coding-agent hosts supported by the CLI. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub(crate) enum CodingAgent { + /// `claude-code` remains an input alias for older Relay configuration. + ClaudeCode, + Codex, + Hermes, +} + +#[derive(Debug, Clone, Copy)] +pub(super) struct AgentDescriptor { + argument: &'static str, + install_argument: &'static str, + label: &'static str, + executable: &'static str, + hook_path: &'static str, + version_product: &'static str, + minimum_version: (u64, u64, u64), + hook_events: &'static [&'static str], + direct_hook_entries: bool, +} + +impl CodingAgent { + pub(crate) const ALL: [Self; 3] = [Self::ClaudeCode, Self::Codex, Self::Hermes]; + + const fn descriptor(self) -> AgentDescriptor { + match self { + Self::ClaudeCode => claude::DESCRIPTOR, + Self::Codex => codex::DESCRIPTOR, + Self::Hermes => hermes::DESCRIPTOR, + } + } + + /// Canonical CLI spelling used in generated commands and configuration. + pub(crate) const fn as_arg(self) -> &'static str { + self.descriptor().argument + } + + /// Canonical spelling accepted by persistent integration commands. + pub(crate) const fn install_arg(self) -> &'static str { + self.descriptor().install_argument + } + + /// Human-readable product name used in diagnostics. + pub(crate) const fn label(self) -> &'static str { + self.descriptor().label + } + + /// Default executable name used for discovery and transparent launch. + pub(crate) const fn executable(self) -> &'static str { + self.descriptor().executable + } + + /// Stable gateway endpoint used by lifecycle hooks. + pub(crate) const fn hook_path(self) -> &'static str { + self.descriptor().hook_path + } + + /// Complete lifecycle event set installed for this host. + pub(crate) const fn hook_events(self) -> &'static [&'static str] { + self.descriptor().hook_events + } + + /// Hermes stores direct command entries; plugin hosts use nested command-hook groups. + pub(crate) const fn uses_direct_hook_entries(self) -> bool { + self.descriptor().direct_hook_entries + } + + pub(crate) fn minimum_version(self) -> Version { + let (major, minor, patch) = self.descriptor().minimum_version; + Version::new(major, minor, patch) + } + + pub(crate) fn version_requirement(self) -> String { + let descriptor = self.descriptor(); + format!( + "{} {} or newer", + descriptor.version_product, + self.minimum_version() + ) + } + + /// Parses and validates the first version line emitted by the host CLI. + pub(crate) fn validate_version_output(self, raw: &str) -> Result { + let first_line = raw.lines().next().unwrap_or_default().trim(); + let version = self.parse_version(first_line).ok_or_else(|| { + format!( + "could not parse `{} --version` output {:?}; NeMo Relay requires {}", + self.executable(), + raw.trim(), + self.version_requirement() + ) + })?; + if version < self.minimum_version() || !version.pre.is_empty() { + return Err(format!( + "{} {version} is unsupported; NeMo Relay requires {}", + self.descriptor().version_product, + self.version_requirement() + )); + } + Ok(version) + } + + fn parse_version(self, raw: &str) -> Option { + match self { + Self::ClaudeCode => claude::parse_version(raw), + Self::Codex => codex::parse_version(raw), + Self::Hermes => hermes::parse_version(raw), + } + } + + /// Infers a host from an executable basename. + pub(crate) fn infer(command: &str) -> Option { + let command = command.trim_matches(['"', '\'']); + if command.starts_with('@') { + return None; + } + let name = command + .rsplit(['/', '\\']) + .next() + .unwrap_or(command) + .to_ascii_lowercase(); + let name = [".exe", ".cmd", ".bat", ".com"] + .into_iter() + .find_map(|suffix| name.strip_suffix(suffix)) + .unwrap_or(&name); + match name { + "claude" | "claude-code" => Some(Self::ClaudeCode), + "codex" => Some(Self::Codex), + "hermes" | "hermes-agent" => Some(Self::Hermes), + _ => None, + } + } +} + +impl crate::installation::marketplace::MarketplaceHost for CodingAgent { + fn install_arg(self) -> &'static str { + self.install_arg() + } + + fn label(self) -> &'static str { + self.label() + } + + fn executable(self) -> &'static str { + self.executable() + } + + fn validate_version_output(self, output: &str) -> Result<(), String> { + self.validate_version_output(output).map(|_| ()) + } + + fn version_requirement(self) -> String { + self.version_requirement() + } + + fn marketplace_manifest_relative(self) -> &'static [&'static str] { + match self { + Self::Codex => &[".agents", "plugins", "marketplace.json"], + Self::ClaudeCode => &[".claude-plugin", "marketplace.json"], + Self::Hermes => unreachable!("Hermes does not use marketplace layout"), + } + } + + fn plugin_manifest_relative(self) -> &'static [&'static str] { + match self { + Self::Codex => &[".codex-plugin", "plugin.json"], + Self::ClaudeCode => &[".claude-plugin", "plugin.json"], + Self::Hermes => unreachable!("Hermes does not use marketplace layout"), + } + } + + fn marketplace_manifest(self, marketplace: &str, plugin: &str) -> serde_json::Value { + marketplace_manifest(self, marketplace, plugin) + } + + fn plugin_manifest(self, plugin: &str) -> serde_json::Value { + plugin_manifest(self, plugin) + } + + fn plugin_mcp_config(self, server: serde_json::Value) -> Result { + plugin_mcp_config(self, server) + } + + fn plugin_hooks( + self, + relay: &std::path::Path, + generation_fence: &std::path::Path, + generation_token: &str, + ) -> Result { + let command = crate::hooks::persistent_hook_forward_command( + relay, + self, + generation_fence, + generation_token, + )?; + Ok(crate::hooks::generated_hooks(self, &command)) + } + + fn plugin_registration_args(self, plugin_id: &str) -> Vec { + match self { + Self::Codex => vec!["plugin".into(), "add".into(), plugin_id.into()], + Self::ClaudeCode => vec![ + "plugin".into(), + "install".into(), + plugin_id.into(), + "--scope".into(), + "user".into(), + ], + Self::Hermes => unreachable!("Hermes does not register marketplace plugins"), + } + } + + fn plugin_removal_args(self, plugin_name: &str, plugin_id: &str) -> Vec { + match self { + Self::Codex => vec!["plugin".into(), "remove".into(), plugin_id.into()], + Self::ClaudeCode => vec!["plugin".into(), "uninstall".into(), plugin_name.into()], + Self::Hermes => unreachable!("Hermes does not register marketplace plugins"), + } + } + + fn registration_report( + self, + options: &crate::installation::marketplace::state::PluginInstallOptions, + runner: &dyn crate::installation::marketplace::host::CommandRunner, + ) -> Result { + match self { + Self::Codex => { + crate::installation::marketplace::host::codex_registration_report(options, runner) + } + Self::ClaudeCode => { + crate::installation::marketplace::host::claude_registration_report(options, runner) + } + Self::Hermes => unreachable!("Hermes does not register marketplace plugins"), + } + } + + fn setup_may_mutate_before_success(self) -> bool { + !matches!(self, Self::Codex) + } + + fn unsafe_generation_fence_error(self, problem: &str) -> String { + match self { + Self::Codex => format!( + "cannot safely replace or uninstall an existing Codex plugin because its MCP generation marker {problem}; close all Codex clients and standalone `nemo-relay mcp` processes, run `codex plugin remove nemo-relay-plugin@nemo-relay-local` and `codex plugin marketplace remove nemo-relay-local`, remove the stale marketplace and state from the selected install directory, then run `nemo-relay install codex --force` to create a fenced install (and `nemo-relay uninstall codex` afterward if removal was intended)" + ), + Self::ClaudeCode => format!( + "cannot safely replace or uninstall an existing Claude Code plugin because its MCP generation marker {problem}; close all Claude Code clients and standalone `nemo-relay mcp` processes, run `claude plugin uninstall nemo-relay-plugin` and `claude plugin marketplace remove nemo-relay-local`, remove the stale marketplace and state from the selected install directory, then run `nemo-relay install claude-code --force` to create a fenced install (and `nemo-relay uninstall claude-code` afterward if removal was intended)" + ), + Self::Hermes => unreachable!("Hermes does not use marketplace generations"), + } + } + + fn accepts_legacy_hook_only_plugin(self) -> bool { + matches!(self, Self::ClaudeCode) + } + + fn accepts_mcp_environment_superset(self) -> bool { + matches!(self, Self::Codex) + } + + fn local_install_exists( + self, + marketplace_root: &std::path::Path, + plugin_root: &std::path::Path, + plugin_manifest: &std::path::Path, + generation_fence: &std::path::Path, + ) -> bool { + match self { + Self::Codex => marketplace_root.exists(), + Self::ClaudeCode => { + plugin_manifest.exists() + || plugin_root.join(".mcp.json").exists() + || generation_fence.exists() + } + Self::Hermes => unreachable!("Hermes does not use marketplace installs"), + } + } + + fn setup_action_description(self, action: &str) -> String { + setup_action_description(self, action) + } + + fn snapshot_setup( + self, + ) -> Result, String> { + let snapshot = snapshot_setup(self)?; + Ok(Some( + crate::installation::marketplace::PluginSetupSnapshot::new(move || { + restore_setup_snapshot(&snapshot) + }), + )) + } + + fn setup_plugin( + self, + gateway_url: &str, + plugin_root: &std::path::Path, + generation_token: Option<&str>, + ) -> Result<(), String> { + setup_marketplace_plugin(self, gateway_url, plugin_root, generation_token) + } + + fn uninstall_plugin( + self, + gateway_url: &str, + plugin_root: &std::path::Path, + ) -> Result<(), String> { + uninstall_marketplace_plugin(self, gateway_url, plugin_root) + } + + fn doctor_plugin( + self, + gateway_url: &str, + plugin_root: &std::path::Path, + generation_token: Option<&str>, + ) -> Result<(), String> { + doctor_marketplace_plugin(self, gateway_url, plugin_root, generation_token) + } + + fn doctor_plugin_json( + self, + gateway_url: &str, + plugin_root: &std::path::Path, + ) -> Result { + doctor_marketplace_plugin_json(self, gateway_url, plugin_root) + } +} + +pub(crate) fn marketplace_manifest( + agent: CodingAgent, + marketplace: &str, + plugin: &str, +) -> serde_json::Value { + match agent { + CodingAgent::Codex => codex::assets::marketplace_manifest(marketplace, plugin), + CodingAgent::ClaudeCode => claude::assets::marketplace_manifest(marketplace, plugin), + CodingAgent::Hermes => unreachable!("Hermes does not install a marketplace plugin"), + } +} + +pub(crate) fn plugin_manifest(agent: CodingAgent, plugin: &str) -> serde_json::Value { + match agent { + CodingAgent::Codex => codex::assets::plugin_manifest(plugin), + CodingAgent::ClaudeCode => claude::assets::plugin_manifest(plugin), + CodingAgent::Hermes => unreachable!("Hermes does not install a marketplace plugin"), + } +} + +pub(crate) fn plugin_mcp_config( + agent: CodingAgent, + server: serde_json::Value, +) -> Result { + match agent { + CodingAgent::Codex => codex::assets::mcp_config(server), + CodingAgent::ClaudeCode => Ok(claude::assets::mcp_config(server)), + CodingAgent::Hermes => unreachable!("Hermes does not install a marketplace plugin"), + } +} + +#[cfg(test)] +pub(crate) fn codex_mcp_env_vars_from( + environment: impl IntoIterator, + config: Option<&serde_json::Value>, +) -> Vec { + codex::assets::mcp_env_vars_from(environment, config) +} + +pub(crate) fn prepare_launch( + agent: CodingAgent, + launch: &mut crate::process::PreparedAgentLaunch, + gateway_url: &str, + resolved: &crate::configuration::ResolvedConfig, + dry_run: bool, +) -> Result<(), crate::error::CliError> { + match agent { + CodingAgent::Codex => codex::launch::prepare(launch, gateway_url), + CodingAgent::ClaudeCode => claude::launch::prepare(launch, gateway_url, dry_run), + CodingAgent::Hermes => hermes::launch::prepare( + launch, + resolved.agents.hermes.hooks_path.as_deref(), + dry_run, + ), + } +} + +pub(crate) fn configured(agent: CodingAgent, configs: &crate::configuration::AgentConfigs) -> bool { + config(agent, configs).command.is_some() + || matches!(agent, CodingAgent::Hermes) && configs.hermes.hooks_path.is_some() +} + +pub(crate) const fn config( + agent: CodingAgent, + configs: &crate::configuration::AgentConfigs, +) -> &crate::configuration::AgentCommandConfig { + match agent { + CodingAgent::ClaudeCode => &configs.claude, + CodingAgent::Codex => &configs.codex, + CodingAgent::Hermes => &configs.hermes, + } +} + +pub(crate) fn hook_status( + agent: CodingAgent, + configs: &crate::configuration::AgentConfigs, +) -> Result { + match agent { + CodingAgent::Codex => codex::doctor::hook_status(), + CodingAgent::ClaudeCode => claude::doctor::hook_status(), + CodingAgent::Hermes => hermes::doctor::hook_status(configs.hermes.hooks_path.as_deref()), + } +} + +pub(crate) enum SetupSnapshot { + Codex(CodexSetupSnapshot), + Claude(ClaudeSetupSnapshot), +} + +pub(crate) fn setup_action_description(agent: CodingAgent, action: &str) -> String { + match (agent, action) { + (CodingAgent::Codex, "configure") => { + "configure Codex provider and trust plugin-owned hooks".into() + } + (CodingAgent::Codex, "restore") => "remove Codex provider and plugin hook trust".into(), + (CodingAgent::Codex, "doctor") => "check Codex provider and plugin-owned hooks".into(), + (CodingAgent::ClaudeCode, "configure") => { + "enable Claude Code provider routing through NeMo Relay".into() + } + (CodingAgent::ClaudeCode, "restore") => { + "restore Claude Code provider routing from NeMo Relay backup".into() + } + (CodingAgent::ClaudeCode, "doctor") => "check Claude Code provider routing".into(), + _ => unreachable!("unsupported setup action"), + } +} + +pub(crate) fn snapshot_setup(agent: CodingAgent) -> Result { + match agent { + CodingAgent::Codex => snapshot_codex_setup().map(SetupSnapshot::Codex), + CodingAgent::ClaudeCode => snapshot_claude_setup().map(SetupSnapshot::Claude), + CodingAgent::Hermes => unreachable!("Hermes does not use marketplace setup"), + } +} + +pub(crate) fn restore_setup_snapshot(snapshot: &SetupSnapshot) -> Result<(), String> { + match snapshot { + SetupSnapshot::Codex(snapshot) => restore_codex_setup(snapshot), + SetupSnapshot::Claude(snapshot) => restore_claude_setup(snapshot), + } +} + +pub(crate) fn setup_marketplace_plugin( + agent: CodingAgent, + gateway_url: &str, + plugin_root: &Path, + generation_token: Option<&str>, +) -> Result<(), String> { + match agent { + CodingAgent::Codex => { + install_codex_plugin_with_generation(gateway_url, plugin_root, generation_token) + } + CodingAgent::ClaudeCode => enable_claude_provider(gateway_url), + CodingAgent::Hermes => unreachable!("Hermes does not use marketplace setup"), + } +} + +pub(crate) fn uninstall_marketplace_plugin( + agent: CodingAgent, + gateway_url: &str, + plugin_root: &Path, +) -> Result<(), String> { + match agent { + CodingAgent::Codex => uninstall_codex_plugin(gateway_url, plugin_root), + CodingAgent::ClaudeCode => restore_claude_provider(gateway_url), + CodingAgent::Hermes => unreachable!("Hermes does not use marketplace setup"), + } +} + +pub(crate) fn doctor_marketplace_plugin( + agent: CodingAgent, + gateway_url: &str, + plugin_root: &Path, + generation_token: Option<&str>, +) -> Result<(), String> { + match agent { + CodingAgent::Codex => doctor_plugin_with_generation( + CodingAgent::Codex, + gateway_url, + plugin_root, + generation_token, + ), + CodingAgent::ClaudeCode => doctor_plugin(CodingAgent::ClaudeCode, gateway_url, plugin_root), + CodingAgent::Hermes => unreachable!("Hermes does not use marketplace setup"), + } +} + +pub(crate) fn doctor_marketplace_plugin_json( + agent: CodingAgent, + gateway_url: &str, + plugin_root: &Path, +) -> Result { + match agent { + CodingAgent::Codex => doctor_plugin_json(CodingAgent::Codex, gateway_url, plugin_root), + CodingAgent::ClaudeCode => { + doctor_plugin_json(CodingAgent::ClaudeCode, gateway_url, plugin_root) + } + CodingAgent::Hermes => unreachable!("Hermes does not use marketplace setup"), + } +} + +pub(crate) fn install_integration( + agent: CodingAgent, + command: crate::installation::InstallRequest, +) -> Result { + match agent { + CodingAgent::Hermes => hermes::install::install(command), + CodingAgent::Codex => codex::install::install(command), + CodingAgent::ClaudeCode => claude::install::install(command), + } +} + +pub(crate) fn uninstall_integration( + agent: CodingAgent, + command: crate::installation::UninstallRequest, +) -> Result { + match agent { + CodingAgent::Hermes => hermes::install::uninstall(command), + CodingAgent::Codex => codex::install::uninstall(command), + CodingAgent::ClaudeCode => claude::install::uninstall(command), + } +} + +pub(crate) fn detected_install_integrations(candidates: &[CodingAgent]) -> Vec { + candidates + .iter() + .copied() + .filter(|agent| crate::process::resolve_executable(agent.executable()).is_some()) + .collect() +} + +pub(crate) fn installed_integrations( + candidates: &[CodingAgent], + install_dir: Option<&Path>, +) -> Vec { + let install_dir = install_dir + .map(Path::to_path_buf) + .unwrap_or_else(crate::installation::marketplace::default_marketplace_install_dir); + candidates + .iter() + .copied() + .filter(|agent| match agent { + CodingAgent::Codex | CodingAgent::ClaudeCode => { + crate::installation::marketplace::persisted_state_exists(*agent, &install_dir) + } + CodingAgent::Hermes => hermes::install::config_path() + .is_ok_and(|path| hermes::persistent_state_exists(&path)), + }) + .collect() +} + +pub(crate) fn doctor_integration( + agent: CodingAgent, + options: &crate::installation::marketplace::state::PluginInstallOptions, +) -> Result<(), crate::error::CliError> { + match agent { + CodingAgent::Codex | CodingAgent::ClaudeCode => { + crate::installation::marketplace::doctor_marketplace_integration(agent, options) + } + CodingAgent::Hermes => { + let runner = crate::installation::marketplace::host::RealCommandRunner; + hermes::install::doctor(options, &runner).map_err(crate::error::CliError::Install) + } + } +} + +pub(crate) fn doctor_integration_report( + agent: CodingAgent, + options: &crate::installation::marketplace::state::PluginInstallOptions, +) -> Result { + match agent { + CodingAgent::Codex | CodingAgent::ClaudeCode => { + crate::installation::marketplace::doctor_marketplace_report(agent, options) + } + CodingAgent::Hermes => { + let runner = crate::installation::marketplace::host::RealCommandRunner; + hermes::install::doctor_json_value(options, &runner) + .map_err(crate::error::CliError::Install) + } + } +} + +struct PendingIntegrationReadiness { + agent: CodingAgent, + state_path: PathBuf, + receiver: std::sync::mpsc::Receiver, +} + +pub(crate) fn collect_default_integration_readiness() +-> Vec { + const TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5); + + let install_dir = crate::installation::marketplace::default_marketplace_install_dir(); + let agents = installed_integrations(&CodingAgent::ALL, Some(&install_dir)); + let pending = agents + .into_iter() + .map(|agent| spawn_integration_readiness(agent, install_dir.clone())) + .collect::>(); + let deadline = std::time::Instant::now() + TIMEOUT; + pending + .into_iter() + .map(|pending| { + receive_integration_readiness( + pending, + deadline.saturating_duration_since(std::time::Instant::now()), + &install_dir, + ) + }) + .collect() +} + +fn receive_integration_readiness( + pending: PendingIntegrationReadiness, + timeout: std::time::Duration, + install_dir: &Path, +) -> crate::installation::marketplace::HostPluginReadiness { + match pending.receiver.recv_timeout(timeout) { + Ok(readiness) => readiness, + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => failed_integration_readiness( + pending.agent, + pending.state_path, + install_dir, + "timed out while collecting persistent-integration readiness", + ), + Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => failed_integration_readiness( + pending.agent, + pending.state_path, + install_dir, + "persistent-integration readiness collector stopped unexpectedly", + ), + } +} + +#[cfg(test)] +pub(crate) fn receive_integration_readiness_for_test( + agent: CodingAgent, + state_path: PathBuf, + receiver: std::sync::mpsc::Receiver, + install_dir: &Path, + timeout: std::time::Duration, +) -> crate::installation::marketplace::HostPluginReadiness { + receive_integration_readiness( + PendingIntegrationReadiness { + agent, + state_path, + receiver, + }, + timeout, + install_dir, + ) +} + +fn spawn_integration_readiness( + agent: CodingAgent, + install_dir: PathBuf, +) -> PendingIntegrationReadiness { + let state_path = match agent { + CodingAgent::Codex | CodingAgent::ClaudeCode => { + crate::installation::marketplace::marketplace_state_path(agent, &install_dir) + } + CodingAgent::Hermes => { + hermes::install::config_path().unwrap_or_else(|_| install_dir.join("hermes.json")) + } + }; + let worker_state_path = state_path.clone(); + let worker_install_dir = install_dir.clone(); + let (sender, receiver) = std::sync::mpsc::sync_channel(1); + std::thread::spawn(move || { + let options = crate::installation::marketplace::state::PluginInstallOptions { + install_dir: worker_install_dir, + operation_lock_dir: PathBuf::new(), + force: false, + dry_run: false, + skip_doctor: true, + }; + let runner = crate::installation::marketplace::host::RealCommandRunner; + let readiness = match agent { + CodingAgent::Codex | CodingAgent::ClaudeCode => { + crate::installation::marketplace::collect_marketplace_readiness( + agent, &options, &runner, + ) + } + CodingAgent::Hermes => { + hermes::install::collect_readiness(&worker_state_path, &options, &runner) + } + }; + let _ = sender.send(readiness); + }); + PendingIntegrationReadiness { + agent, + state_path, + receiver, + } +} + +fn failed_integration_readiness( + agent: CodingAgent, + state_path: PathBuf, + install_dir: &Path, + details: &str, +) -> crate::installation::marketplace::HostPluginReadiness { + let (marketplace, plugin) = match agent { + CodingAgent::Codex | CodingAgent::ClaudeCode => { + let (marketplace, plugin) = + crate::installation::marketplace::marketplace_install_roots(agent, install_dir); + (Some(marketplace), Some(plugin)) + } + CodingAgent::Hermes => (None, None), + }; + let mut readiness = crate::installation::marketplace::HostPluginReadiness { + host: agent.install_arg().to_string(), + remediation: format!("nemo-relay install {} --force", agent.install_arg()), + state_path, + marketplace, + plugin, + checks: Vec::new(), + relay: None, + host_plugin_registered: None, + host_marketplace_registered: None, + plugin_setup: None, + }; + readiness.push("Host readiness", Err(details.to_string())); + readiness +} + +pub(crate) use crate::process::portable_executable_path; +pub(crate) use crate::process::shell_quote_arg_for_platform; +#[cfg(test)] +pub(crate) use crate::process::strip_windows_verbatim_prefix; +pub(crate) use claude::host::{ClaudeSetupSnapshot, restore_claude_setup, snapshot_claude_setup}; +pub(crate) use codex::host::{CodexSetupSnapshot, restore_codex_setup, snapshot_codex_setup}; + +use std::path::{Path, PathBuf}; + +use serde_json::{Value, json}; + +use claude::host::claude_settings_base_url; +use codex::host::{ + codex_hook_trust_report, codex_hook_trust_report_with_generation, codex_hooks_installed, + codex_hooks_installed_with_generation, codex_provider_installed, empty_codex_hook_trust_report, + install_codex_with_generation, uninstall_codex, +}; +use shared::host::{current_exe, healthz, print_check, print_info}; + +#[cfg(test)] +pub(super) use crate::bootstrap::DEFAULT_URL; + +pub(crate) fn install_codex_plugin_with_generation( + gateway_url: &str, + plugin_root: &Path, + generation_token: Option<&str>, +) -> Result<(), String> { + install_codex_with_generation( + gateway_url, + &plugin_root.join("hooks").join("hooks.json"), + generation_token, + ) + .map(|_| ()) +} + +pub(crate) fn stop_plugin_gateway() -> Result<(), String> { + crate::bootstrap::state::stop_owned_and_reset(crate::bootstrap::DEFAULT_URL) +} + +pub(crate) fn uninstall_codex_plugin(gateway_url: &str, plugin_root: &Path) -> Result<(), String> { + uninstall_codex(gateway_url, &plugin_root.join("hooks").join("hooks.json")).map(|_| ()) +} + +pub(crate) fn enable_claude_provider(gateway_url: &str) -> Result<(), String> { + claude::host::enable_claude_provider(gateway_url) +} + +pub(crate) fn restore_claude_provider(gateway_url: &str) -> Result<(), String> { + claude::host::restore_claude_provider(gateway_url) +} + +pub(crate) fn doctor_plugin( + agent: CodingAgent, + gateway_url: &str, + plugin_root: &Path, +) -> Result<(), String> { + doctor_plugin_with_generation(agent, gateway_url, plugin_root, None) +} + +pub(crate) fn doctor_plugin_with_generation( + agent: CodingAgent, + gateway_url: &str, + plugin_root: &Path, + generation_token: Option<&str>, +) -> Result<(), String> { + if doctor_ok( + agent, + gateway_url, + Some(&plugin_root.join("hooks").join("hooks.json")), + generation_token, + )? { + Ok(()) + } else { + Err(format!("{} plugin doctor checks failed", agent.as_arg())) + } +} + +pub(crate) fn doctor_plugin_json( + agent: CodingAgent, + gateway_url: &str, + plugin_root: &Path, +) -> Result { + let plugin_binary = current_exe().ok().is_some_and(|path| path.exists()); + let sidecar_running = healthz(gateway_url); + let (checks, ok, codex_trust) = match agent { + CodingAgent::ClaudeCode => { + let provider = claude_settings_base_url().as_deref() == Some(gateway_url); + ( + json!({ + "plugin_binary": plugin_binary, + "sidecar_running": sidecar_running, + "claude_provider_routing": provider + }), + plugin_binary && provider, + None, + ) + } + CodingAgent::Codex => { + let plugin_hooks_path = plugin_root.join("hooks").join("hooks.json"); + let provider = codex_provider_installed(gateway_url); + let hooks = codex_hooks_installed(&plugin_hooks_path)?; + let trust = if hooks { + codex_hook_trust_report(&plugin_hooks_path)? + } else { + empty_codex_hook_trust_report() + }; + let hooks_trusted = trust.ready(); + ( + json!({ + "plugin_binary": plugin_binary, + "sidecar_running": sidecar_running, + "codex_provider_alias": provider, + "codex_hooks": hooks, + "codex_hooks_trusted": hooks_trusted + }), + plugin_binary && provider && hooks && hooks_trusted, + Some(trust), + ) + } + other => { + return Err(format!( + "plugin doctor supports claude and codex, got {}", + other.as_arg() + )); + } + }; + let mut report = json!({ + "ok": ok, + "sidecar_health": if sidecar_running { + "running" + } else { + "not_running_mcp_start" + }, + "checks": checks + }); + if let Some(trust) = codex_trust { + report["codex_hook_trust"] = trust.to_json(); + } + Ok(report) +} + +fn doctor_ok( + agent: CodingAgent, + gateway_url: &str, + plugin_hooks_path: Option<&Path>, + generation_token: Option<&str>, +) -> Result { + let mut ok = true; + ok &= print_check( + "plugin binary", + current_exe().ok().is_some_and(|path| path.exists()), + ); + if healthz(gateway_url) { + print_info("sidecar health", "running"); + } else { + print_info( + "sidecar health", + "not running; the plugin MCP starts it when the host launches", + ); + } + match agent { + CodingAgent::ClaudeCode => { + ok &= print_check( + "claude provider routing", + claude_settings_base_url().as_deref() == Some(gateway_url), + ); + } + CodingAgent::Codex => { + let plugin_hooks_path = plugin_hooks_path + .ok_or_else(|| "Codex plugin hooks path is required for doctor".to_string())?; + let provider = codex_provider_installed(gateway_url); + let hooks = codex_hooks_installed_with_generation(plugin_hooks_path, generation_token)?; + ok &= print_check("codex provider alias", provider); + ok &= print_check("codex hooks", hooks); + let trust = if hooks { + codex_hook_trust_report_with_generation(plugin_hooks_path, generation_token)? + } else { + empty_codex_hook_trust_report() + }; + ok &= print_check("codex hooks trusted and enabled", trust.ready()); + if !trust.ready() { + print_info("codex hook trust", &trust.summary()); + } + } + other => { + return Err(format!( + "plugin doctor supports claude and codex, got {}", + other.as_arg() + )); + } + } + Ok(ok) +} + +#[cfg(test)] +use crate::bootstrap::*; +#[cfg(test)] +use crate::hooks::generated_hooks; +#[cfg(test)] +use claude::host::*; +#[cfg(test)] +use codex::app_server::*; +#[cfg(test)] +use codex::host::*; +#[cfg(test)] +use shared::host::*; + +#[cfg(test)] +#[path = "../../tests/coverage/agents/plugin_host_tests.rs"] +mod host_tests; + +#[cfg(test)] +#[path = "../../tests/coverage/agents/coding_agent_tests.rs"] +mod tests; diff --git a/crates/cli/src/adapters/mod.rs b/crates/cli/src/agents/shared/adapters.rs similarity index 99% rename from crates/cli/src/adapters/mod.rs rename to crates/cli/src/agents/shared/adapters.rs index 2f8dcf76f..38d4b0794 100644 --- a/crates/cli/src/adapters/mod.rs +++ b/crates/cli/src/agents/shared/adapters.rs @@ -1,8 +1,11 @@ // SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +#[path = "../claude/adapter.rs"] pub(crate) mod claude_code; +#[path = "../codex/adapter.rs"] pub(crate) mod codex; +#[path = "../hermes/adapter.rs"] pub(crate) mod hermes; pub(crate) const SKILL_LOAD_SOURCE_KEY: &str = "skill_load_source"; @@ -13,11 +16,11 @@ use nemo_relay::api::scope::COMPACTION_EVENT_NAME; use serde_json::{Map, Value, json}; use uuid::Uuid; -use crate::config::header_string; -use crate::json_path::{ +use crate::configuration::header_string; +use crate::events::json_path::{ string_at, string_at_any as first_string_at, value_at, value_at_any as first_value_at, }; -use crate::model::{ +use crate::events::{ AgentKind, LlmHintEvent, NormalizedEvent, SessionEvent, SubagentEvent, ToolEvent, }; @@ -982,5 +985,5 @@ fn normalize_name(name: &str) -> String { } #[cfg(test)] -#[path = "../../tests/coverage/adapters_tests.rs"] +#[path = "../../../tests/coverage/agents/adapters_tests.rs"] mod tests; diff --git a/crates/cli/src/alignment/mod.rs b/crates/cli/src/agents/shared/alignment.rs similarity index 98% rename from crates/cli/src/alignment/mod.rs rename to crates/cli/src/agents/shared/alignment.rs index a12d57db6..5561ca495 100644 --- a/crates/cli/src/alignment/mod.rs +++ b/crates/cli/src/agents/shared/alignment.rs @@ -13,12 +13,17 @@ use axum::http::HeaderMap; use nemo_relay::api::llm::LlmRequest; use serde_json::{Map, Value, json}; -use crate::config::header_string; -pub(crate) use crate::json_path::{string_at_any as json_string_at, value_at_any as json_value_at}; -use crate::model::{AgentKind, LlmEvent, NormalizedEvent, SessionEvent, SubagentEvent, ToolEvent}; +use crate::configuration::header_string; +pub(crate) use crate::events::json_path::{ + string_at_any as json_string_at, value_at_any as json_value_at, +}; +use crate::events::{AgentKind, LlmEvent, NormalizedEvent, SessionEvent, SubagentEvent, ToolEvent}; +#[path = "../claude/alignment.rs"] pub(crate) mod claude_code; +#[path = "../codex/alignment.rs"] pub(crate) mod codex; +#[path = "../hermes/alignment.rs"] pub(crate) mod hermes; const REQUEST_AFFINITY_KEY_MIN_CHARS: usize = 24; @@ -132,7 +137,7 @@ static ANTHROPIC_COUNT_TOKENS_REQUEST_EXTRACTOR: AnthropicCountTokensRequestExtr impl ProviderRequestExtractor for OpenAiResponsesRequestExtractor { fn gateway_session_id(&self, headers: &HeaderMap, body: &Value) -> Option { gateway_header_session_id(headers) - .or_else(|| codex::prompt_cache_session_id(body, GatewayRouteKind::OpenAiResponses)) + .or_else(|| codex::responses_session_id(body, GatewayRouteKind::OpenAiResponses)) .or_else(|| openai_body_session_id(body, GatewayRouteKind::OpenAiResponses)) } @@ -551,13 +556,14 @@ pub(crate) fn gateway_forward_headers( codex::strip_chatgpt_auth_for_openai_route(headers, route, has_openai_replacement_key) } -/// Read the explicit subagent header from a gateway request. -/// -/// Unlike session ids, there is intentionally no body fallback here: subagent -/// body fields are provider-specific and easy to confuse with tool-call payload -/// content. -pub(crate) fn gateway_subagent_id(headers: &HeaderMap) -> Option { +/// Resolve subagent ownership from an explicit Relay header or trusted agent metadata. +pub(crate) fn gateway_subagent_id( + headers: &HeaderMap, + body: &Value, + route: GatewayRouteKind, +) -> Option { header_string(headers, "x-nemo-relay-subagent-id") + .or_else(|| codex::responses_subagent_id(body, route)) } /// Resolve a correlation identifier from a header or known JSON body paths. @@ -1165,5 +1171,5 @@ pub(crate) fn merge_metadata(left: Value, right: Value) -> Value { } #[cfg(test)] -#[path = "../../tests/coverage/alignment_tests.rs"] +#[path = "../../../tests/coverage/agents/alignment_tests.rs"] mod tests; diff --git a/crates/cli/src/agents/shared/host.rs b/crates/cli/src/agents/shared/host.rs new file mode 100644 index 000000000..23176b891 --- /dev/null +++ b/crates/cli/src/agents/shared/host.rs @@ -0,0 +1,67 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Filesystem and platform helpers shared by host configuration. + +use std::env; +use std::fs; +use std::path::{Path, PathBuf}; + +use serde_json::{Value, json}; +use toml_edit::{DocumentMut, Item, Table}; + +pub(crate) use crate::bootstrap::current_exe; +pub(crate) use crate::filesystem::{atomic_write, atomic_write_private}; +pub(crate) use crate::gateway::client::healthz; + +pub(crate) fn shell_quote(path: &Path) -> String { + shell_quote_for_platform(path, cfg!(windows)) +} + +pub(crate) fn shell_quote_for_platform(path: &Path, windows: bool) -> String { + crate::process::shell_quote_arg_for_platform(&path.display().to_string(), windows) +} + +pub(crate) fn ensure_table<'a>(doc: &'a mut DocumentMut, name: &str) -> &'a mut Table { + if !doc.as_table().contains_key(name) || !doc[name].is_table() { + doc[name] = Item::Table(Table::new()); + } + doc[name].as_table_mut().expect("table was just inserted") +} + +pub(crate) fn read_json_object(path: &Path) -> Result { + if !path.exists() { + return Ok(json!({})); + } + let raw = fs::read_to_string(path) + .map_err(|error| format!("failed to read {}: {error}", path.display()))?; + let value = serde_json::from_str::(&raw) + .map_err(|error| format!("invalid JSON in {}: {error}", path.display()))?; + if value.is_object() { + Ok(value) + } else { + Err(format!("{} must contain a JSON object", path.display())) + } +} + +pub(crate) fn write_json(path: &Path, value: &Value) -> Result<(), String> { + let mut bytes = serde_json::to_vec_pretty(value).map_err(|error| error.to_string())?; + bytes.push(b'\n'); + atomic_write(path, &bytes) +} + +pub(crate) fn home_dir() -> Result { + env::var_os("HOME") + .or_else(|| env::var_os("USERPROFILE")) + .map(PathBuf::from) + .ok_or_else(|| "cannot determine home directory (set HOME or USERPROFILE)".into()) +} + +pub(crate) fn print_check(label: &str, ok: bool) -> bool { + println!("{} {label}", if ok { "ok" } else { "missing" }); + ok +} + +pub(crate) fn print_info(label: &str, message: &str) { + println!("info {label}: {message}"); +} diff --git a/crates/cli/src/agents/shared/mod.rs b/crates/cli/src/agents/shared/mod.rs new file mode 100644 index 000000000..336ed9437 --- /dev/null +++ b/crates/cli/src/agents/shared/mod.rs @@ -0,0 +1,8 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Agent-neutral event adaptation and gateway alignment mechanics. + +pub(crate) mod adapters; +pub(crate) mod alignment; +pub(super) mod host; diff --git a/crates/cli/src/banner.rs b/crates/cli/src/banner.rs index 7b290df5d..5d26350b6 100644 --- a/crates/cli/src/banner.rs +++ b/crates/cli/src/banner.rs @@ -312,5 +312,5 @@ fn print_plain_header() { } #[cfg(test)] -#[path = "../tests/coverage/banner_tests.rs"] +#[path = "../tests/coverage/shared/banner_tests.rs"] mod tests; diff --git a/crates/cli/src/bootstrap/mod.rs b/crates/cli/src/bootstrap/mod.rs new file mode 100644 index 000000000..54c890e00 --- /dev/null +++ b/crates/cli/src/bootstrap/mod.rs @@ -0,0 +1,531 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Thin bootstrap coordinator for the existing Relay gateway server. + +pub(crate) mod state; + +use std::env; +use std::ffi::OsString; +use std::fs; +use std::net::SocketAddr; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::sync::{Arc, Mutex}; +use std::thread; +use std::time::{Duration, Instant}; + +use serde::Deserialize; + +use crate::configuration::resolve_persistent_server_config; +use crate::error::CliError; +use crate::gateway::client::{ + self as gateway_client, RelayHealth, VerifiedHttpError, VerifiedHttpResponse, loopback_bind, + probe_with_instance as probe_relay_health_with_instance, +}; +use crate::process::detached; +use crate::server::GatewayOverrides; +#[cfg(test)] +pub(crate) use detached::{ + WINDOWS_CREATE_BREAKAWAY_FROM_JOB, WINDOWS_CREATE_NEW_PROCESS_GROUP, WINDOWS_CREATE_NO_WINDOW, + WINDOWS_JOB_OBJECT_LIMIT_BREAKAWAY_OK, WINDOWS_JOB_OBJECT_LIMIT_SILENT_BREAKAWAY_OK, + windows_creation_flags as windows_detached_creation_flags, +}; +#[cfg(test)] +pub(crate) use state::lock_name as bootstrap_lock_name; +use state::{BOOTSTRAP_STATE_DIR_ENV, state_dir as bootstrap_state_dir}; + +pub(crate) const DEFAULT_BIND: &str = "127.0.0.1:47632"; +pub(crate) const DEFAULT_URL: &str = "http://127.0.0.1:47632"; +pub(crate) const HEALTHZ_TIMEOUT: Duration = Duration::from_millis(500); +pub(crate) const BOOTSTRAP_PROTOCOL_VERSION: u64 = 2; + +pub(super) const BOOTSTRAP_LOCK_TIMEOUT: Duration = Duration::from_secs(20); +const BOOTSTRAP_START_TIMEOUT: Duration = Duration::from_secs(10); + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct GatewayEndpoint { + pub(crate) address: SocketAddr, + pub(crate) url: String, + pub(crate) instance_id: String, +} + +/// Inputs required to identify and, when absent, start one persistent gateway. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct GatewaySpec { + bind: SocketAddr, + launch_args: Vec, + bootstrap_fingerprint: Option, + user_config_scope: bool, +} + +impl GatewaySpec { + pub(crate) fn new(bind: SocketAddr) -> Self { + Self { + bind, + launch_args: Vec::new(), + bootstrap_fingerprint: None, + user_config_scope: false, + } + } + + pub(crate) fn with_launch_args(mut self, args: Vec) -> Self { + self.launch_args = args; + self + } + + pub(crate) fn with_fingerprint(mut self, fingerprint: impl Into) -> Self { + self.bootstrap_fingerprint = Some(fingerprint.into()); + self + } + + pub(crate) fn with_user_config_scope(mut self) -> Self { + self.user_config_scope = true; + self + } + + pub(crate) fn bind(&self) -> SocketAddr { + self.bind + } + + /// Return the compatible gateway already bound at this endpoint, or start the existing server + /// under a per-user lock and wait for authenticated readiness. + pub(crate) fn acquire(&self) -> Result { + acquire_gateway(self) + } + + pub(crate) fn recover(&self, expected_instance: &str) -> Result { + recover_gateway(self, expected_instance) + } + + pub(crate) fn healthy_instance(&self, url: &str) -> Option { + gateway_client::compatible_instance_id(url, self.bootstrap_fingerprint.as_deref()) + } + + pub(crate) fn existing_healthy_instance(&self, url: &str) -> Result, String> { + match probe_relay_health_with_instance(url, self.bootstrap_fingerprint.as_deref()) { + (RelayHealth::Compatible, instance_id) => Ok(instance_id), + (RelayHealth::Unavailable, _) => Ok(None), + (RelayHealth::Incompatible, _) => Err(incompatible_relay_error(url)), + (RelayHealth::Foreign, _) => Err(foreign_listener_error(url)), + } + } + + pub(crate) fn post_verified( + &self, + url: &str, + path: &str, + headers: &[(String, String)], + body: &[u8], + timeout: Duration, + max_response_bytes: usize, + ) -> Result { + let Some(fingerprint) = self.bootstrap_fingerprint.as_deref() else { + return Err(VerifiedHttpError::missing_fingerprint()); + }; + gateway_client::post_verified( + url, + fingerprint, + path, + headers, + body, + timeout, + max_response_bytes, + ) + } +} + +fn acquire_gateway(spec: &GatewaySpec) -> Result { + if !spec.bind.ip().is_loopback() { + return Err(format!( + "plugin gateways require a loopback bind address, got {}", + spec.bind + )); + } + let url = format!("http://{}", spec.bind); + if spec.bind.port() != 0 { + match probe_relay_health_with_instance(&url, spec.bootstrap_fingerprint.as_deref()) { + (RelayHealth::Compatible, instance_id) => { + return compatible_endpoint(spec.bind, url, instance_id); + } + (RelayHealth::Incompatible, _) => return Err(incompatible_relay_error(&url)), + // A gateway may already be binding while another MCP process owns the + // startup lock. Serialize before deciding whether either state is a + // genuine conflict. + (RelayHealth::Foreign | RelayHealth::Unavailable, _) => {} + } + } + + let state = bootstrap_state_dir()?; + state::create_private_dir(&state)?; + let _startup_lock = state::lock_endpoint(&state, &url)?; + if spec.bind.port() == 0 { + return start_gateway(spec, &state); + } + match probe_relay_health_with_instance(&url, spec.bootstrap_fingerprint.as_deref()) { + (RelayHealth::Compatible, instance_id) => compatible_endpoint(spec.bind, url, instance_id), + (RelayHealth::Incompatible, _) => Err(incompatible_relay_error(&url)), + (RelayHealth::Foreign, _) => Err(foreign_listener_error(&url)), + (RelayHealth::Unavailable, _) => start_gateway(spec, &state), + } +} + +fn recover_gateway(spec: &GatewaySpec, expected_instance: &str) -> Result { + let requested_url = format!("http://{}", spec.bind); + let state = bootstrap_state_dir()?; + state::create_private_dir(&state)?; + let _startup_lock = state::lock_endpoint(&state, &requested_url)?; + + if spec.bind.port() != 0 { + match probe_relay_health_with_instance( + &requested_url, + spec.bootstrap_fingerprint.as_deref(), + ) { + (RelayHealth::Compatible, instance_id) => { + return compatible_endpoint(spec.bind, requested_url, instance_id); + } + (RelayHealth::Incompatible, _) => return Err(incompatible_relay_error(&requested_url)), + (RelayHealth::Foreign, _) => return Err(foreign_listener_error(&requested_url)), + (RelayHealth::Unavailable, _) => {} + } + } + + if let Some(previous) = state::read_recovery(&state, &requested_url)? + && previous.from_instance == expected_instance + { + if !previous.endpoint_url.is_empty() + && !previous.to_instance.is_empty() + && spec.healthy_instance(&previous.endpoint_url).as_deref() + == Some(previous.to_instance.as_str()) + { + let address = loopback_bind(&previous.endpoint_url)?; + return compatible_endpoint(address, previous.endpoint_url, Some(previous.to_instance)); + } + return Err("shared Relay gateway became unhealthy after its coordinated restart".into()); + } + + // Record the attempt while holding the startup lock. If the replacement + // dies before readiness, another overlapping MCP must not start a second + // replacement. + state::write_recovery( + &state, + &requested_url, + &state::RecoveryRecord { + from_instance: expected_instance.into(), + endpoint_url: String::new(), + to_instance: String::new(), + }, + )?; + let endpoint = start_gateway(spec, &state)?; + state::write_recovery( + &state, + &requested_url, + &state::RecoveryRecord { + from_instance: expected_instance.into(), + endpoint_url: endpoint.url.clone(), + to_instance: endpoint.instance_id.clone(), + }, + )?; + Ok(endpoint) +} + +fn compatible_endpoint( + address: SocketAddr, + url: String, + instance_id: Option, +) -> Result { + let instance_id = instance_id.ok_or_else(|| foreign_listener_error(&url))?; + Ok(GatewayEndpoint { + address, + url, + instance_id, + }) +} + +fn foreign_listener_error(url: &str) -> String { + format!( + "{url} is occupied by a service that is not a compatible NeMo Relay gateway; stop that service or configure another port" + ) +} + +fn incompatible_relay_error(url: &str) -> String { + format!( + "{url} is occupied by NeMo Relay with a different version or persistent configuration; stop it, wait for idle shutdown, or reinstall the integration with --force" + ) +} + +fn start_gateway(spec: &GatewaySpec, state: &Path) -> Result { + let relay = relay_binary()?; + let ready_path = state.join(format!( + "gateway-{}-{}.ready.json", + std::process::id(), + uuid::Uuid::now_v7() + )); + let _ = fs::remove_file(&ready_path); + let shutdown_token = uuid::Uuid::now_v7().to_string(); + let mut command = Command::new(relay); + command + .arg("--bind") + .arg(spec.bind.to_string()) + .arg("--ready-file") + .arg(&ready_path) + .args(&spec.launch_args) + .env( + crate::configuration::PLUGIN_IDLE_TIMEOUT_ENV, + plugin_idle_timeout()?.as_secs().to_string(), + ) + .env( + crate::configuration::BOOTSTRAP_FINGERPRINT_ENV, + spec.bootstrap_fingerprint.as_deref().unwrap_or_default(), + ) + .env(BOOTSTRAP_STATE_DIR_ENV, state) + .env(state::BOOTSTRAP_SHUTDOWN_TOKEN_ENV, &shutdown_token) + .env_remove(crate::installation::generation::GENERATION_FILE_ENV) + .env_remove(crate::installation::generation::GENERATION_TOKEN_ENV) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + if spec.user_config_scope { + command.env("NEMO_RELAY_CONFIG_SCOPE", "user"); + if let Some(config_dir) = crate::configuration::user_config_dir() { + fs::create_dir_all(&config_dir).map_err(|error| { + format!( + "failed to create gateway working directory {}: {error}", + config_dir.display() + ) + })?; + command.current_dir(config_dir); + } + } + detached::configure_detached(&mut command); + let child = detached::spawn_detached(&mut command) + .map_err(|error| format!("failed to spawn nemo-relay gateway: {error}"))?; + let mut child = ArmedChild::new(child); + let deadline = Instant::now() + BOOTSTRAP_START_TIMEOUT; + while Instant::now() < deadline { + if let Some(endpoint) = read_ready_file(&ready_path)? + && (endpoint.address == spec.bind + || (spec.bind.port() == 0 && endpoint.address.ip() == spec.bind.ip())) + && spec.healthy_instance(&endpoint.url).as_deref() + == Some(endpoint.instance_id.as_str()) + { + if let Err(error) = hand_off_to_reaper(child.disarm()) { + let _ = fs::remove_file(&ready_path); + return Err(error); + } + let _ = fs::remove_file(&ready_path); + return Ok(endpoint); + } + match child.try_wait() { + Ok(Some(status)) => { + let _ = fs::remove_file(&ready_path); + return Err(format!( + "nemo-relay gateway exited before becoming ready at http://{}: {status}", + spec.bind + )); + } + Ok(None) => {} + Err(error) => { + let _ = fs::remove_file(&ready_path); + return Err(format!( + "failed to inspect nemo-relay gateway process: {error}" + )); + } + } + thread::sleep(Duration::from_millis(50)); + } + let _ = fs::remove_file(&ready_path); + Err(format!( + "nemo-relay gateway did not become ready at http://{}", + spec.bind + )) +} + +fn hand_off_to_reaper(child: detached::DetachedChild) -> Result<(), String> { + hand_off_to_reaper_with( + child, + |slot| { + thread::Builder::new() + .name("nemo-relay-gateway-wait".into()) + .spawn(move || { + let mut slot = slot.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); + if let Some(mut child) = slot.take() { + let _ = child.wait(); + } + }) + .map(|_| ()) + }, + detached::terminate_tree, + ) +} + +fn hand_off_to_reaper_with( + child: T, + spawn: impl FnOnce(Arc>>) -> std::io::Result<()>, + terminate: impl FnOnce(&mut T), +) -> Result<(), String> { + let slot = Arc::new(Mutex::new(Some(child))); + if let Err(error) = spawn(Arc::clone(&slot)) { + let mut slot = slot.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); + if let Some(mut child) = slot.take() { + terminate(&mut child); + } + return Err(format!("failed to start gateway reaper thread: {error}")); + } + Ok(()) +} + +struct ArmedChild(Option); + +impl ArmedChild { + fn new(child: detached::DetachedChild) -> Self { + Self(Some(child)) + } + + fn try_wait(&mut self) -> std::io::Result> { + self.0 + .as_mut() + .expect("armed gateway child is present") + .try_wait() + } + + fn disarm(mut self) -> detached::DetachedChild { + self.0.take().expect("armed gateway child is present") + } +} + +impl Drop for ArmedChild { + fn drop(&mut self) { + if let Some(mut child) = self.0.take() { + detached::terminate_tree(&mut child); + } + } +} + +#[derive(Deserialize)] +struct ReadyRecord { + service: String, + version: String, + bootstrap_protocol: u64, + address: SocketAddr, + instance_id: String, +} + +fn read_ready_file(path: &Path) -> Result, String> { + let bytes = match fs::read(path) { + Ok(bytes) => bytes, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => { + return Err(format!( + "failed to read gateway readiness {}: {error}", + path.display() + )); + } + }; + let record = serde_json::from_slice::(&bytes).map_err(|error| { + format!( + "failed to parse gateway readiness {}: {error}", + path.display() + ) + })?; + if record.service != "nemo-relay" + || record.version != env!("CARGO_PKG_VERSION") + || record.bootstrap_protocol != BOOTSTRAP_PROTOCOL_VERSION + || record.instance_id.is_empty() + { + return Err(format!( + "gateway readiness {} has an incompatible identity", + path.display() + )); + } + Ok(Some(GatewayEndpoint { + address: record.address, + url: format!("http://{}", record.address), + instance_id: record.instance_id, + })) +} + +/// Persistent plugin settings shared by MCP bootstrap and forward-only hooks. +pub(crate) struct PluginGatewaySpec { + pub(crate) gateway: GatewaySpec, + pub(crate) max_hook_payload_bytes: usize, +} + +pub(crate) fn resolve_plugin_gateway( + server_args: &GatewayOverrides, + bind: SocketAddr, +) -> Result { + let mut persistent_args = server_args.clone(); + persistent_args.bind = Some(bind); + let resolved = resolve_persistent_server_config(&persistent_args)?; + let bootstrap_fingerprint = resolved + .bootstrap_fingerprint + .expect("persistent gateway resolution sets a bootstrap fingerprint"); + let max_hook_payload_bytes = resolved.gateway.max_hook_payload_bytes; + let launch_args = [ + ("--openai-base-url", resolved.gateway.openai_base_url), + ("--anthropic-base-url", resolved.gateway.anthropic_base_url), + ( + "--max-hook-payload-bytes", + resolved.gateway.max_hook_payload_bytes.to_string(), + ), + ( + "--max-passthrough-body-bytes", + resolved.gateway.max_passthrough_body_bytes.to_string(), + ), + ] + .into_iter() + .flat_map(|(flag, value)| [OsString::from(flag), OsString::from(value)]) + .collect(); + Ok(PluginGatewaySpec { + gateway: GatewaySpec::new(bind) + .with_launch_args(launch_args) + .with_fingerprint(bootstrap_fingerprint) + .with_user_config_scope(), + max_hook_payload_bytes, + }) +} + +pub(super) fn relay_binary() -> Result { + if let Ok(path) = env::var("NEMO_RELAY_PLUGIN_BINARY") { + let path = PathBuf::from(path); + if path.exists() { + return Ok(path); + } + return Err(format!( + "NEMO_RELAY_PLUGIN_BINARY does not exist: {}", + path.display() + )); + } + current_exe() +} + +pub(crate) fn current_exe() -> Result { + env::current_exe().map_err(|error| format!("failed to resolve current executable: {error}")) +} + +pub(crate) fn plugin_idle_timeout() -> Result { + let raw = + env::var(crate::configuration::PLUGIN_IDLE_TIMEOUT_ENV).unwrap_or_else(|_| "300".into()); + let seconds = raw.parse::().map_err(|error| { + format!( + "{} must be a positive integer: {error}", + crate::configuration::PLUGIN_IDLE_TIMEOUT_ENV + ) + })?; + if seconds == 0 { + return Err(format!( + "{} must be greater than 0", + crate::configuration::PLUGIN_IDLE_TIMEOUT_ENV + )); + } + Ok(Duration::from_secs(seconds)) +} + +pub(crate) fn plugin_heartbeat_interval() -> Result { + Ok((plugin_idle_timeout()? / 3).clamp(Duration::from_millis(100), Duration::from_secs(30))) +} + +#[cfg(test)] +#[path = "../../tests/coverage/shared/bootstrap_tests.rs"] +mod tests; diff --git a/crates/cli/src/bootstrap/state.rs b/crates/cli/src/bootstrap/state.rs new file mode 100644 index 000000000..920b01102 --- /dev/null +++ b/crates/cli/src/bootstrap/state.rs @@ -0,0 +1,342 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Per-user startup lock and ownership record for the shared gateway. + +use std::env; +use std::fs::{self, OpenOptions}; +use std::net::SocketAddr; +use std::path::{Path, PathBuf}; +use std::thread; +use std::time::{Duration, Instant}; + +use reqwest::Url; +use serde::{Deserialize, Serialize}; + +use crate::filesystem::{LockAttempt, atomic_write, try_lock_exclusive}; + +use super::{BOOTSTRAP_LOCK_TIMEOUT, BOOTSTRAP_PROTOCOL_VERSION}; +use crate::gateway::client::{RelayHealth, probe, request_shutdown}; + +pub(crate) const BOOTSTRAP_STATE_DIR_ENV: &str = "NEMO_RELAY_BOOTSTRAP_STATE_DIR"; +pub(crate) const BOOTSTRAP_SHUTDOWN_TOKEN_ENV: &str = "NEMO_RELAY_BOOTSTRAP_SHUTDOWN_TOKEN"; +const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(5); + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +pub(super) struct OwnerRecord { + service: String, + version: String, + bootstrap_protocol: u64, + pid: u32, + url: String, + shutdown_token: String, + bootstrap_fingerprint: Option, +} + +#[derive(Debug, Deserialize, PartialEq, Eq, Serialize)] +pub(super) struct RecoveryRecord { + pub(super) from_instance: String, + pub(super) endpoint_url: String, + pub(super) to_instance: String, +} + +impl OwnerRecord { + fn new(pid: u32, url: &str, shutdown_token: &str, fingerprint: Option<&str>) -> Self { + Self { + service: "nemo-relay".into(), + version: env!("CARGO_PKG_VERSION").into(), + bootstrap_protocol: BOOTSTRAP_PROTOCOL_VERSION, + pid, + url: url.into(), + shutdown_token: shutdown_token.into(), + bootstrap_fingerprint: fingerprint.map(str::to_owned), + } + } + + fn valid_for(&self, url: &str) -> bool { + self.service == "nemo-relay" + && self.bootstrap_protocol == BOOTSTRAP_PROTOCOL_VERSION + && self.url == url + && !self.shutdown_token.is_empty() + && self + .bootstrap_fingerprint + .as_deref() + .is_some_and(|fingerprint| !fingerprint.is_empty()) + } +} + +/// Removes this process's ownership record when the gateway server exits. +#[derive(Debug)] +pub(crate) struct OwnerGuard { + path: PathBuf, + record: OwnerRecord, +} + +impl Drop for OwnerGuard { + fn drop(&mut self) { + let _ = remove_if_matches(&self.path, &self.record); + } +} + +pub(crate) fn state_dir() -> Result { + crate::configuration::user_config_dir() + .map(|path| path.join("bootstrap")) + .ok_or_else(|| { + "cannot determine the per-user NeMo Relay bootstrap state directory; set HOME or USERPROFILE" + .into() + }) +} + +pub(crate) fn create_private_dir(path: &Path) -> Result<(), String> { + fs::create_dir_all(path) + .map_err(|error| format!("failed to create {}: {error}", path.display()))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + + fs::set_permissions(path, fs::Permissions::from_mode(0o700)) + .map_err(|error| format!("failed to secure {}: {error}", path.display()))?; + } + Ok(()) +} + +pub(crate) fn owner_path(state: &Path, url: &str) -> PathBuf { + state.join(format!("sidecar-{}.owner.json", lock_name(url))) +} + +pub(crate) fn lock_path(state: &Path, url: &str) -> PathBuf { + state.join(format!("gateway-{}.lock", lock_name(url))) +} + +fn recovery_path(state: &Path, url: &str) -> PathBuf { + state.join(format!("gateway-{}.recovery.json", lock_name(url))) +} + +pub(super) fn read_recovery(state: &Path, url: &str) -> Result, String> { + let path = recovery_path(state, url); + match fs::read(&path) { + Ok(bytes) => serde_json::from_slice(&bytes).map(Some).map_err(|error| { + format!( + "failed to parse gateway recovery {}: {error}", + path.display() + ) + }), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(format!( + "failed to read gateway recovery {}: {error}", + path.display() + )), + } +} + +pub(super) fn write_recovery( + state: &Path, + url: &str, + record: &RecoveryRecord, +) -> Result<(), String> { + let path = recovery_path(state, url); + let bytes = serde_json::to_vec(record) + .map_err(|error| format!("failed to encode gateway recovery: {error}"))?; + atomic_write(&path, &bytes) +} + +pub(crate) fn lock_endpoint(state: &Path, url: &str) -> Result { + lock_endpoint_for(state, url, BOOTSTRAP_LOCK_TIMEOUT) +} + +pub(crate) fn lock_endpoint_for( + state: &Path, + url: &str, + timeout: Duration, +) -> Result { + create_private_dir(state)?; + let path = lock_path(state, url); + let lock = OpenOptions::new() + .create(true) + .truncate(false) + .read(true) + .write(true) + .open(&path) + .map_err(|error| format!("failed to open gateway lock {}: {error}", path.display()))?; + let deadline = Instant::now() + timeout; + loop { + match try_lock_exclusive(&lock) { + Ok(LockAttempt::Acquired) => return Ok(lock), + Ok(LockAttempt::Contended) if Instant::now() < deadline => { + thread::sleep(Duration::from_millis(50)); + } + Ok(LockAttempt::Contended) => { + return Err(format!( + "timed out waiting for gateway startup lock {}", + path.display() + )); + } + Err(error) => { + return Err(format!( + "failed to acquire gateway startup lock {}: {error}", + path.display() + )); + } + } + } +} + +pub(crate) fn publish_owner_from_env( + address: SocketAddr, + shutdown_token: Option<&str>, +) -> Result, String> { + let state = env::var_os(BOOTSTRAP_STATE_DIR_ENV); + if state.is_none() && shutdown_token.is_none() { + return Ok(None); + } + let state = state + .map(PathBuf::from) + .ok_or_else(|| format!("{BOOTSTRAP_STATE_DIR_ENV} is required for managed bootstrap"))?; + if !state.is_absolute() { + return Err(format!( + "{BOOTSTRAP_STATE_DIR_ENV} must be an absolute path, got {}", + state.display() + )); + } + let token = shutdown_token + .filter(|token| !token.is_empty()) + .ok_or_else(|| { + format!("{BOOTSTRAP_SHUTDOWN_TOKEN_ENV} is required for managed bootstrap") + })?; + if !address.ip().is_loopback() { + return Err(format!( + "managed bootstrap ownership requires a loopback address, got {address}" + )); + } + create_private_dir(&state)?; + let url = format!("http://{address}"); + let fingerprint = env::var(crate::configuration::BOOTSTRAP_FINGERPRINT_ENV) + .ok() + .filter(|value| !value.is_empty()); + let record = OwnerRecord::new(std::process::id(), &url, token, fingerprint.as_deref()); + let path = owner_path(&state, &url); + write_owner_record(&path, &record)?; + Ok(Some(OwnerGuard { path, record })) +} + +pub(crate) fn stop_owned_and_reset(url: &str) -> Result<(), String> { + let state = state_dir()?; + if !state.exists() { + return Ok(()); + } + let _lock = lock_endpoint(&state, url)?; + let path = owner_path(&state, url); + let Some(owner) = read_owner_record(&path)? else { + return Ok(()); + }; + if !owner.valid_for(url) { + return Err(format!( + "refusing to stop gateway from invalid ownership record {}", + path.display() + )); + } + match probe(url, owner.bootstrap_fingerprint.as_deref()) { + RelayHealth::Unavailable => { + remove_if_matches(&path, &owner)?; + return Ok(()); + } + RelayHealth::Compatible => {} + RelayHealth::Incompatible | RelayHealth::Foreign => { + return Err(format!( + "refusing to stop an unverified process at managed gateway URL {url}" + )); + } + } + request_shutdown( + url, + owner + .bootstrap_fingerprint + .as_deref() + .expect("validated owner record has a bootstrap fingerprint"), + &owner.shutdown_token, + )?; + let deadline = Instant::now() + SHUTDOWN_TIMEOUT; + loop { + match probe(url, owner.bootstrap_fingerprint.as_deref()) { + RelayHealth::Unavailable => break, + RelayHealth::Compatible if Instant::now() < deadline => { + thread::sleep(Duration::from_millis(50)); + } + RelayHealth::Compatible => { + return Err(format!("managed Relay gateway at {url} did not stop")); + } + RelayHealth::Incompatible | RelayHealth::Foreign => { + return Err(format!( + "a different process replaced the managed Relay gateway at {url} during shutdown" + )); + } + } + } + remove_if_matches(&path, &owner) +} + +fn write_owner_record(path: &Path, record: &OwnerRecord) -> Result<(), String> { + let bytes = serde_json::to_vec(record) + .map_err(|error| format!("failed to encode gateway ownership: {error}"))?; + atomic_write(path, &bytes) +} + +pub(super) fn read_owner_record(path: &Path) -> Result, String> { + match fs::read(path) { + Ok(bytes) => serde_json::from_slice(&bytes).map(Some).map_err(|error| { + format!( + "failed to parse gateway ownership {}: {error}", + path.display() + ) + }), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(format!( + "failed to read gateway ownership {}: {error}", + path.display() + )), + } +} + +fn remove_if_matches(path: &Path, expected: &OwnerRecord) -> Result<(), String> { + if read_owner_record(path)?.as_ref() != Some(expected) { + return Ok(()); + } + match fs::remove_file(path) { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(format!( + "failed to remove gateway ownership {}: {error}", + path.display() + )), + } +} + +pub(crate) fn lock_name(url: &str) -> String { + let raw = Url::parse(url) + .ok() + .and_then(|parsed| { + let host = parsed.host_str()?; + let port = parsed.port_or_known_default()?; + Some(format!("{host}-{port}")) + }) + .unwrap_or_else(|| url.to_string()); + let sanitized = raw + .chars() + .map(|character| { + if character.is_ascii_alphanumeric() || matches!(character, '.' | '-' | '_') { + character + } else { + '_' + } + }) + .collect::(); + if sanitized.is_empty() { + "unknown".into() + } else { + sanitized + } +} + +#[cfg(test)] +#[path = "../../tests/coverage/shared/bootstrap_state_tests.rs"] +mod tests; diff --git a/crates/cli/src/completions_install.rs b/crates/cli/src/commands/completions/install.rs similarity index 97% rename from crates/cli/src/completions_install.rs rename to crates/cli/src/commands/completions/install.rs index 8c0ef63de..e83fa5e88 100644 --- a/crates/cli/src/completions_install.rs +++ b/crates/cli/src/commands/completions/install.rs @@ -12,7 +12,7 @@ use std::path::{Path, PathBuf}; use clap_complete::Shell; -use crate::config::Cli; +use crate::commands::root::Cli; use crate::error::CliError; /// Generates the completion script for `$SHELL` and writes it to the matching shell-specific @@ -112,5 +112,5 @@ fn write_atomic(target: &Path, bytes: &[u8]) -> Result<(), CliError> { } #[cfg(test)] -#[path = "../tests/coverage/completions_install_tests.rs"] +#[path = "../../../tests/coverage/shared/completions_install_tests.rs"] mod tests; diff --git a/crates/cli/src/commands/completions/mod.rs b/crates/cli/src/commands/completions/mod.rs new file mode 100644 index 000000000..77df5e826 --- /dev/null +++ b/crates/cli/src/commands/completions/mod.rs @@ -0,0 +1,45 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +mod install; + +use std::process::ExitCode; + +use clap::Args; + +use clap::CommandFactory; + +use crate::commands::root::Cli; +use crate::error::CliError; + +#[derive(Debug, Clone, Args)] +pub(crate) struct CompletionsCommand { + #[arg(value_enum)] + pub(crate) shell: Option, + #[arg(long)] + pub(crate) install: bool, +} + +pub(super) fn execute(command: CompletionsCommand) -> Result { + if command.install { + let path = install::install(command.shell)?; + println!("✓ Installed completions: {}", path.display()); + } else { + generate_to(command.shell, &mut std::io::stdout())?; + } + Ok(ExitCode::SUCCESS) +} + +pub(super) fn generate_to( + shell: Option, + writer: &mut dyn std::io::Write, +) -> Result<(), CliError> { + let shell = shell.ok_or_else(|| { + CliError::Config( + "missing shell argument; pass a shell name (bash, zsh, fish, ...) or use `--install` to auto-detect from $SHELL".into(), + ) + })?; + let mut command = Cli::command(); + clap_complete::generate(shell, &mut command, "nemo-relay", writer); + Ok(()) +} diff --git a/crates/cli/src/commands/configure/mod.rs b/crates/cli/src/commands/configure/mod.rs new file mode 100644 index 000000000..bd57c008d --- /dev/null +++ b/crates/cli/src/commands/configure/mod.rs @@ -0,0 +1,37 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use std::process::ExitCode; + +use clap::Args; + +use super::root::AgentArg; +use crate::error::CliError; + +mod model; +mod wizard; + +pub(super) use wizard::run; + +#[derive(Debug, Clone, Args)] +pub(crate) struct ConfigCommand { + #[arg(value_enum)] + pub(crate) agent: Option, + /// Reset Relay configuration for the selected scope. Persistent Hermes integration state is + /// managed separately with `nemo-relay uninstall hermes`. + #[arg(long)] + pub(crate) reset: bool, + /// Configuration scope to reset. Defaults to the project configuration. + #[arg(long, value_enum, requires = "reset")] + pub(crate) scope: Option, +} + +pub(super) async fn execute(command: ConfigCommand) -> Result { + let agent = command.agent.map(Into::into); + if command.reset { + model::reset(command.scope.unwrap_or(model::ConfigScope::Project), agent)?; + } else { + wizard::run(agent).await?; + } + Ok(ExitCode::SUCCESS) +} diff --git a/crates/cli/src/setup/model.rs b/crates/cli/src/commands/configure/model.rs similarity index 70% rename from crates/cli/src/setup/model.rs rename to crates/cli/src/commands/configure/model.rs index fe03003e7..336ef249b 100644 --- a/crates/cli/src/setup/model.rs +++ b/crates/cli/src/commands/configure/model.rs @@ -1,19 +1,19 @@ // SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -//! Testable setup configuration model and file helpers. +//! Testable setup configuration model and file helpers owned by the configure command. use std::path::{Path, PathBuf}; +use clap::ValueEnum; use toml_edit::{DocumentMut, Item, Table, value}; -use crate::config::CodingAgent; -use crate::config::{PluginsEditCommand, PluginsScopeArgs}; +use crate::agents::CodingAgent; use crate::error::CliError; -use crate::installer::{hermes_hooks, hook_forward_command, merge_hermes_config}; +use crate::plugins::{ConfigurationScope, PluginsEditRequest}; /// Where the setup saves its output. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] pub(crate) enum ConfigScope { /// `./.nemo-relay/config.toml` (walked-up workspace dir). Project, @@ -24,7 +24,7 @@ pub(crate) enum ConfigScope { } impl ConfigScope { - pub(super) fn label(self) -> &'static str { + pub(crate) fn label(self) -> &'static str { match self { Self::Project => "project ./.nemo-relay/config.toml (recommended)", Self::Global => "global ~/.config/nemo-relay/config.toml", @@ -36,26 +36,18 @@ impl ConfigScope { /// Maps the base setup scope to the plugin editor target for the guided continuation. /// /// `Project` and `Both` configure the project `plugins.toml`; `Global` configures the user -/// `plugins.toml`. Returns the existing `PluginsEditCommand` so the in-process editor behaves +/// `plugins.toml`. Returns the existing `PluginsEditRequest` so the in-process editor behaves /// exactly like the equivalent `nemo-relay plugins edit` invocation. -pub(super) fn plugins_edit_command_for_scope(scope: ConfigScope) -> PluginsEditCommand { +pub(crate) fn plugins_edit_command_for_scope(scope: ConfigScope) -> PluginsEditRequest { let scope = match scope { - ConfigScope::Project | ConfigScope::Both => PluginsScopeArgs { - user: false, - project: true, - global: false, - }, - ConfigScope::Global => PluginsScopeArgs { - user: true, - project: false, - global: false, - }, + ConfigScope::Project | ConfigScope::Both => ConfigurationScope::Project, + ConfigScope::Global => ConfigurationScope::User, }; - PluginsEditCommand { scope } + PluginsEditRequest { scope } } /// Returns the exact command a user runs to resume plugin setup after skipping the continuation. -pub(super) fn plugins_resume_command(scope: ConfigScope) -> &'static str { +pub(crate) fn plugins_resume_command(scope: ConfigScope) -> &'static str { match scope { ConfigScope::Project | ConfigScope::Both => "nemo-relay plugins edit --project", ConfigScope::Global => "nemo-relay plugins edit", @@ -67,10 +59,6 @@ pub(super) fn plugins_resume_command(scope: ConfigScope) -> &'static str { pub(crate) struct SetupAnswers { pub scope: ConfigScope, pub agents: Vec, - /// Path recorded under `[agents.hermes].hooks_path` when hermes is selected. Set by `run` - /// from `hermes_hooks_path_for_scope` so the wizard preview shows the file the launcher - /// will reference. `None` when hermes wasn't selected. - pub hermes_hooks_path: Option, } /// Scans `$PATH` for the supported coding-agent binaries and returns the ones present. @@ -86,20 +74,11 @@ pub(crate) fn detect_installed_agents_in(path_var: Option<&std::ffi::OsStr>) -> let Some(path_var) = path_var else { return Vec::new(); }; - // Pairs of (CodingAgent, exec name to look for on $PATH). - let candidates = [ - (CodingAgent::ClaudeCode, "claude"), - (CodingAgent::Codex, "codex"), - (CodingAgent::Hermes, "hermes"), - ]; - candidates + // Keep only agents whose canonical executable resolves on PATH. + CodingAgent::ALL .into_iter() - .filter_map(|(agent, exec)| { - let found = std::env::split_paths(path_var).any(|dir| { - let candidate = dir.join(exec); - candidate.is_file() - }); - found.then_some(agent) + .filter(|agent| { + crate::process::resolve_executable_in_path(agent.executable(), Some(path_var)).is_some() }) .collect() } @@ -119,7 +98,7 @@ pub(crate) fn build_config(answers: &SetupAnswers) -> DocumentMut { doc } -pub(super) fn build_agents_table(answers: &SetupAnswers) -> Option
{ +pub(crate) fn build_agents_table(answers: &SetupAnswers) -> Option
{ if answers.agents.is_empty() { return None; } @@ -129,11 +108,6 @@ pub(super) fn build_agents_table(answers: &SetupAnswers) -> Option
{ let (key, command) = agent_key_and_command(*agent); let mut agent_table = Table::new(); agent_table["command"] = value(command); - if matches!(agent, CodingAgent::Hermes) - && let Some(path) = answers.hermes_hooks_path.as_deref() - { - agent_table["hooks_path"] = value(path.display().to_string()); - } agents_table.insert(key, Item::Table(agent_table)); } Some(agents_table) @@ -177,7 +151,7 @@ pub(crate) fn save_config( // Resolves the global nemo-relay config directory. Prefers `$XDG_CONFIG_HOME/nemo-relay` (matches // `config::user_config_dir`), falling back to `/.config/nemo-relay`. Tests that pass a // tempdir for `home` get hermetic paths unless they set XDG_CONFIG_HOME explicitly. -pub(super) fn global_config_dir(home: &Path) -> PathBuf { +pub(crate) fn global_config_dir(home: &Path) -> PathBuf { if let Some(base) = std::env::var_os("XDG_CONFIG_HOME") { return PathBuf::from(base).join("nemo-relay"); } @@ -187,7 +161,7 @@ pub(super) fn global_config_dir(home: &Path) -> PathBuf { // Writes the wizard-built `doc` to `path`. When `merge_scope` is `Some(agent)` and the file // already exists, preserves any `[agents.]` blocks while replacing the shared sections // and the target agent's block. When `merge_scope` is `None`, just overwrites the file. -pub(super) fn write_or_merge( +pub(crate) fn write_or_merge( path: &Path, doc: &DocumentMut, merge_scope: Option, @@ -215,7 +189,7 @@ pub(super) fn write_or_merge( // Replaces the single `[agents.]` block in `dst` with the one from `src`. If `src` does // not contain that block, the existing entry in `dst` is left as-is. -pub(super) fn merge_agents_entry(dst: &mut DocumentMut, src: &DocumentMut, agent_key: &str) { +pub(crate) fn merge_agents_entry(dst: &mut DocumentMut, src: &DocumentMut, agent_key: &str) { let Some(src_agent) = src .get("agents") .and_then(|item| item.as_table()) @@ -244,22 +218,46 @@ pub(super) fn merge_agents_entry(dst: &mut DocumentMut, src: &DocumentMut, agent /// the existing file and removes only `[agents.]`, leaving every other section intact. /// In both cases this targets the *project* layer; global and system layers are left to direct /// editing because they typically aren't owned by the wizard. -pub(crate) fn reset(agent_hint: Option) -> Result<(), CliError> { - let cwd = std::env::current_dir()?; - let path = cwd.join(".nemo-relay").join("config.toml"); +pub(crate) fn reset(scope: ConfigScope, agent_hint: Option) -> Result<(), CliError> { + if matches!(scope, ConfigScope::Project | ConfigScope::Both) { + let cwd = std::env::current_dir()?; + reset_config_path( + &cwd.join(".nemo-relay").join("config.toml"), + "project", + agent_hint, + )?; + } + if matches!(scope, ConfigScope::Global | ConfigScope::Both) { + let home = home_dir().ok_or_else(|| { + CliError::Config("cannot resolve the home directory for global reset".into()) + })?; + reset_config_path( + &global_config_dir(&home).join("config.toml"), + "global", + agent_hint, + )?; + } + Ok(()) +} + +fn reset_config_path( + path: &Path, + scope: &str, + agent_hint: Option, +) -> Result<(), CliError> { if !path.exists() { - println!(" No project config to reset at {}", path.display()); + println!(" No {scope} config to reset at {}", path.display()); return Ok(()); } match agent_hint { None => { - std::fs::remove_file(&path)?; + std::fs::remove_file(path)?; println!(" ✓ Removed {}", path.display()); println!(" Run `nemo-relay config` to set up again."); } Some(agent) => { let agent_key = agent_key_and_command(agent).0; - let raw = std::fs::read_to_string(&path)?; + let raw = std::fs::read_to_string(path)?; let mut doc: DocumentMut = raw.parse().map_err(|err| { CliError::Config(format!("could not parse existing config: {err}")) })?; @@ -286,78 +284,23 @@ pub(crate) fn reset(agent_hint: Option) -> Result<(), CliError> { if agents.is_empty() { doc.remove("agents"); } - std::fs::write(&path, doc.to_string())?; + std::fs::write(path, doc.to_string())?; println!(" ✓ Removed `[agents.{agent_key}]` from {}", path.display()); } } Ok(()) } -/// Returns the Hermes hooks file path that should be recorded for the selected setup scope. -pub(crate) fn hermes_hooks_path_for_scope( - agents: &[CodingAgent], - scope: ConfigScope, - cwd: &Path, - home: &Path, -) -> Option { - if !agents.contains(&CodingAgent::Hermes) { - return None; - } - match scope { - ConfigScope::Project | ConfigScope::Both => Some(cwd.join(".hermes").join("config.yaml")), - ConfigScope::Global => Some(home.join(".hermes").join("config.yaml")), - } -} - -/// Writes/merges `.hermes/config.yaml` hook config for every scope-applicable location so hermes -/// fires `nemo-relay hook-forward hermes` on every hook event after setup. Idempotent: existing -/// hook entries are preserved and our generated groups are appended only when missing. -/// -/// Returns the list of paths actually written so callers can surface them to the user. -pub(crate) fn install_hermes_hooks( - scope: ConfigScope, - cwd: &Path, - home: &Path, -) -> Result, CliError> { - let generated = hermes_hooks(&hook_forward_command("nemo-relay", CodingAgent::Hermes)); - let mut written = Vec::new(); - for path in hermes_hook_targets(scope, cwd, home) { - let existing = match std::fs::read_to_string(&path) { - Ok(raw) => raw, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(), - Err(error) => return Err(CliError::Io(error)), - }; - let merged = merge_hermes_config(&existing, generated.clone())?; - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent)?; - } - std::fs::write(&path, merged)?; - written.push(path); - } - Ok(written) -} - -pub(super) fn hermes_hook_targets(scope: ConfigScope, cwd: &Path, home: &Path) -> Vec { - let mut targets = Vec::new(); - if matches!(scope, ConfigScope::Project | ConfigScope::Both) { - targets.push(cwd.join(".hermes").join("config.yaml")); - } - if matches!(scope, ConfigScope::Global | ConfigScope::Both) { - targets.push(home.join(".hermes").join("config.yaml")); - } - targets -} - /// Pre-filled wizard defaults read from an existing `config.toml`. When the file is missing or /// unparseable the defaults are all-empty and the wizard behaves like a first-run setup. #[derive(Debug, Clone, Default)] -pub(super) struct Defaults { - pub(super) scope: Option, - pub(super) agents: Vec, +pub(crate) struct Defaults { + pub(crate) scope: Option, + pub(crate) agents: Vec, } impl Defaults { - pub(super) fn has_any(&self) -> bool { + pub(crate) fn has_any(&self) -> bool { self.scope.is_some() || !self.agents.is_empty() } } @@ -365,7 +308,7 @@ impl Defaults { /// Reads the highest-precedence existing config file and derives wizard defaults from it. /// Workspace config wins over global; if both exist, scope defaults to `Both`. Missing or /// malformed files yield `None` (the wizard then behaves as if no config existed). -pub(super) fn read_existing_defaults() -> Option { +pub(crate) fn read_existing_defaults() -> Option { let cwd = std::env::current_dir().ok()?; let home = home_dir(); @@ -399,7 +342,7 @@ pub(super) fn read_existing_defaults() -> Option { }) } -pub(super) fn read_agents_from_doc(doc: &DocumentMut) -> Vec { +pub(crate) fn read_agents_from_doc(doc: &DocumentMut) -> Vec { let Some(table) = doc.get("agents").and_then(|i| i.as_table()) else { return Vec::new(); }; @@ -418,15 +361,11 @@ pub(super) fn read_agents_from_doc(doc: &DocumentMut) -> Vec { found } -pub(super) fn agent_key_and_command(agent: CodingAgent) -> (&'static str, &'static str) { - match agent { - CodingAgent::ClaudeCode => ("claude", "claude"), - CodingAgent::Codex => ("codex", "codex"), - CodingAgent::Hermes => ("hermes", "hermes"), - } +pub(crate) fn agent_key_and_command(agent: CodingAgent) -> (&'static str, &'static str) { + (agent.as_arg(), agent.executable()) } -pub(super) fn preview_paths(scope: ConfigScope, cwd: &Path, home: &Path) -> Vec { +pub(crate) fn preview_paths(scope: ConfigScope, cwd: &Path, home: &Path) -> Vec { let mut paths = Vec::new(); if matches!(scope, ConfigScope::Project | ConfigScope::Both) { paths.push(cwd.join(".nemo-relay").join("config.toml")); @@ -437,7 +376,7 @@ pub(super) fn preview_paths(scope: ConfigScope, cwd: &Path, home: &Path) -> Vec< paths } -pub(super) fn home_dir() -> Option { +pub(crate) fn home_dir() -> Option { std::env::var_os("HOME") .or_else(|| std::env::var_os("USERPROFILE")) .map(PathBuf::from) diff --git a/crates/cli/src/setup.rs b/crates/cli/src/commands/configure/wizard.rs similarity index 91% rename from crates/cli/src/setup.rs rename to crates/cli/src/commands/configure/wizard.rs index 9a717cc36..b0b06c21b 100644 --- a/crates/cli/src/setup.rs +++ b/crates/cli/src/commands/configure/wizard.rs @@ -18,21 +18,17 @@ use dialoguer::theme::ColorfulTheme; use dialoguer::{Confirm, MultiSelect, Select}; use toml_edit::DocumentMut; -use crate::config::CodingAgent; -use crate::error::CliError; - -mod model; - -pub(crate) use self::model::reset; use self::model::{ ConfigScope, SetupAnswers, agent_key_and_command, build_config, detect_installed_agents, - hermes_hook_targets, hermes_hooks_path_for_scope, home_dir, install_hermes_hooks, - plugins_edit_command_for_scope, plugins_resume_command, preview_paths, read_existing_defaults, - save_config, + home_dir, plugins_edit_command_for_scope, plugins_resume_command, preview_paths, + read_existing_defaults, save_config, }; +use super::model; +use crate::agents::CodingAgent; +use crate::error::CliError; #[cfg(test)] -use self::model::{Defaults, global_config_dir, read_agents_from_doc, write_or_merge}; +use self::model::{Defaults, global_config_dir, read_agents_from_doc, reset, write_or_merge}; #[cfg(test)] use self::model::detect_installed_agents_in; @@ -89,11 +85,7 @@ pub(crate) fn prompt_user( print_codex_api_key_guide(); } - Ok(SetupAnswers { - scope, - agents, - hermes_hooks_path: None, - }) + Ok(SetupAnswers { scope, agents }) } /// Top-level setup entry point used by `nemo-relay config` and the easy-path fallback. @@ -104,31 +96,20 @@ pub(crate) fn prompt_user( /// `nemo-relay config` asks the full set so users can configure multiple agents at once. pub(crate) async fn run(agent_hint: Option) -> Result<(), CliError> { let detected = detect_installed_agents(); - let mut answers = prompt_user(&detected, agent_hint)?; + let answers = prompt_user(&detected, agent_hint)?; let cwd = std::env::current_dir()?; let home = home_dir().ok_or_else(|| { CliError::Config("cannot determine home directory (set $HOME or $USERPROFILE)".into()) })?; - answers.hermes_hooks_path = - hermes_hooks_path_for_scope(&answers.agents, answers.scope, &cwd, &home); - let doc = build_config(&answers); - let mut preview_paths = preview_paths(answers.scope, &cwd, &home); - preview_paths.extend( - hermes_hook_targets(answers.scope, &cwd, &home) - .into_iter() - .filter(|_| answers.agents.contains(&CodingAgent::Hermes)), - ); + let preview_paths = preview_paths(answers.scope, &cwd, &home); if !confirm_summary(&preview_paths, &doc)? { return Err(CliError::Config("setup cancelled — no config saved".into())); } - let mut written = save_config(&doc, answers.scope, &cwd, &home, agent_hint)?; - if answers.agents.contains(&CodingAgent::Hermes) { - written.extend(install_hermes_hooks(answers.scope, &cwd, &home)?); - } + let written = save_config(&doc, answers.scope, &cwd, &home, agent_hint)?; println!(); println!(" ✓ Saved:"); for path in &written { @@ -342,5 +323,5 @@ fn setup_error(err: dialoguer::Error) -> CliError { } #[cfg(test)] -#[path = "../tests/coverage/setup_tests.rs"] +#[path = "../../../tests/coverage/shared/setup_tests.rs"] mod tests; diff --git a/crates/cli/src/commands/diagnostics.rs b/crates/cli/src/commands/diagnostics.rs new file mode 100644 index 000000000..bd689bb5c --- /dev/null +++ b/crates/cli/src/commands/diagnostics.rs @@ -0,0 +1,86 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use std::path::PathBuf; +use std::process::ExitCode; + +use clap::Args; +use serde_json::{Value, json}; + +use super::install::InstallTarget; +use super::root::AgentArg; +use crate::error::CliError; + +#[derive(Debug, Clone, Args)] +pub(crate) struct DoctorCommand { + #[arg(value_enum, conflicts_with = "plugin")] + pub(crate) agent: Option, + #[arg(long, value_enum)] + pub(crate) plugin: Option, + #[arg(long)] + pub(crate) install_dir: Option, + #[arg(long)] + pub(crate) json: bool, +} + +#[derive(Debug, Clone, Args)] +pub(crate) struct AgentsCommand { + #[arg(long)] + pub(crate) json: bool, +} + +pub(super) async fn execute(command: DoctorCommand) -> Result { + if let Some(plugin) = command.plugin { + let candidates = plugin.agents(); + let agents = if plugin.is_all() { + crate::agents::installed_integrations(&candidates, command.install_dir.as_deref()) + } else { + candidates + }; + if agents.is_empty() { + return Err(CliError::Install( + "no installed Claude Code, Codex, or Hermes integration state was found".into(), + )); + } + let options = crate::installation::marketplace::plugin_doctor_options(command.install_dir); + if command.json { + let reports = agents + .iter() + .copied() + .map(|agent| crate::agents::doctor_integration_report(agent, &options)) + .collect::, _>>()?; + let ready = reports + .iter() + .all(|report| report.get("ok").and_then(Value::as_bool) == Some(true)); + let output = if reports.len() > 1 { + json!({ "schema_version": 1, "plugins": reports }) + } else { + with_schema(reports.into_iter().next().expect("reports is not empty")) + }; + println!( + "{}", + serde_json::to_string_pretty(&output) + .map_err(|error| CliError::Install(error.to_string()))? + ); + Ok(if ready { + ExitCode::SUCCESS + } else { + ExitCode::FAILURE + }) + } else { + for agent in agents { + crate::agents::doctor_integration(agent, &options)?; + } + Ok(ExitCode::SUCCESS) + } + } else { + crate::diagnostics::run_doctor(command.agent.map(Into::into), command.json).await + } +} + +fn with_schema(mut value: Value) -> Value { + if let Some(object) = value.as_object_mut() { + object.insert("schema_version".into(), json!(1)); + } + value +} diff --git a/crates/cli/src/commands/hook_forward.rs b/crates/cli/src/commands/hook_forward.rs new file mode 100644 index 000000000..9b767570f --- /dev/null +++ b/crates/cli/src/commands/hook_forward.rs @@ -0,0 +1,89 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use std::path::PathBuf; + +use clap::{Args, ValueEnum}; + +use super::root::AgentArg; +use crate::error::CliError; + +#[derive(Debug, Clone, Args)] +pub(crate) struct HookForwardCommand { + /// Coding agent whose canonical lifecycle payload is read from standard input. + #[arg(value_enum)] + pub(crate) agent: AgentArg, + /// Base URL of the Relay gateway that receives the lifecycle payload. + #[arg(long)] + pub(crate) gateway_url: Option, + /// Installer-owned generation marker used to reject stale persistent hooks. + #[arg(long, hide = true)] + pub(crate) generation_file: Option, + /// Expected identity of the installer-owned generation marker. + #[arg(long, hide = true)] + pub(crate) generation_token: Option, + /// Forward to an existing compatible gateway without an install-generation fence. + /// + /// Intended for source plugins and custom automation. This mode verifies the gateway but + /// never launches or recovers Relay. + #[arg(long, conflicts_with_all = ["generation_file", "generation_token"])] + pub(crate) forward_only: bool, + /// Mark a hook as owned by a process-private `nemo-relay run` gateway. + #[arg( + long, + hide = true, + conflicts_with_all = ["generation_file", "generation_token", "forward_only"] + )] + pub(crate) transparent_run: bool, + /// Configuration profile recorded with the forwarded session metadata. + #[arg(long)] + pub(crate) profile: Option, + /// JSON value added to the forwarded session metadata. + #[arg(long)] + pub(crate) session_metadata: Option, + /// Expected gateway behavior recorded with the forwarded session metadata. + #[arg(long, value_enum)] + pub(crate) gateway_mode: Option, + /// Return a failure when the payload cannot be delivered or Relay rejects it. + #[arg(long)] + pub(crate) fail_closed: bool, +} + +impl HookForwardCommand { + fn into_runtime(self) -> crate::hooks::HookForwardRequest { + crate::hooks::HookForwardRequest { + agent: self.agent.into(), + gateway_url: self.gateway_url, + generation_file: self.generation_file, + generation_token: self.generation_token, + forward_only: self.forward_only, + transparent_run: self.transparent_run, + profile: self.profile, + session_metadata: self.session_metadata, + gateway_mode: self.gateway_mode.map(Into::into), + fail_closed: self.fail_closed, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] +#[value(rename_all = "kebab-case")] +pub(crate) enum GatewayModeArg { + HookOnly, + Passthrough, + Required, +} + +impl From for crate::hooks::GatewayMode { + fn from(value: GatewayModeArg) -> Self { + match value { + GatewayModeArg::HookOnly => Self::HookOnly, + GatewayModeArg::Passthrough => Self::Passthrough, + GatewayModeArg::Required => Self::Required, + } + } +} + +pub(super) async fn execute(command: HookForwardCommand) -> Result<(), CliError> { + crate::hooks::hook_forward(command.into_runtime()).await +} diff --git a/crates/cli/src/commands/install.rs b/crates/cli/src/commands/install.rs new file mode 100644 index 000000000..eaf4e1512 --- /dev/null +++ b/crates/cli/src/commands/install.rs @@ -0,0 +1,145 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use std::path::PathBuf; +use std::process::ExitCode; + +use clap::{Args, ValueEnum}; + +use crate::agents::CodingAgent; +use crate::error::CliError; + +#[derive(Debug, Clone, Args)] +pub(crate) struct InstallCommand { + #[arg(value_enum)] + pub(crate) host: InstallTarget, + #[arg(long)] + pub(crate) install_dir: Option, + #[arg(long)] + pub(crate) force: bool, + #[arg(long)] + pub(crate) dry_run: bool, + #[arg(long)] + pub(crate) skip_doctor: bool, +} + +#[derive(Debug, Clone, Args)] +pub(crate) struct UninstallCommand { + #[arg(value_enum)] + pub(crate) host: InstallTarget, + #[arg(long)] + pub(crate) install_dir: Option, + #[arg(long)] + pub(crate) dry_run: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, ValueEnum)] +#[value(rename_all = "kebab-case")] +pub(crate) enum InstallTarget { + Codex, + #[value(name = "claude-code", alias = "claude")] + ClaudeCode, + Hermes, + All, +} + +impl InstallTarget { + pub(crate) fn agents(self) -> Vec { + match self { + Self::Codex => vec![CodingAgent::Codex], + Self::ClaudeCode => vec![CodingAgent::ClaudeCode], + Self::Hermes => vec![CodingAgent::Hermes], + Self::All => vec![ + CodingAgent::Codex, + CodingAgent::ClaudeCode, + CodingAgent::Hermes, + ], + } + } + + pub(crate) const fn is_all(self) -> bool { + matches!(self, Self::All) + } +} + +impl InstallCommand { + pub(crate) fn into_runtime(self) -> crate::installation::InstallRequest { + crate::installation::InstallRequest { + install_dir: self.install_dir, + force: self.force, + dry_run: self.dry_run, + skip_doctor: self.skip_doctor, + } + } +} + +impl UninstallCommand { + pub(crate) fn into_runtime(self) -> crate::installation::UninstallRequest { + crate::installation::UninstallRequest { + install_dir: self.install_dir, + dry_run: self.dry_run, + } + } +} + +pub(super) fn install(command: InstallCommand) -> Result { + let target = command.host; + let request = command.into_runtime(); + let candidates = target.agents(); + let agents = if target.is_all() { + crate::agents::detected_install_integrations(&candidates) + } else { + candidates + }; + if agents.is_empty() { + return Err(CliError::Install( + "no supported Claude Code, Codex, or Hermes host CLI was detected".into(), + )); + } + run_agent_operations(agents, "install", |agent| { + crate::agents::install_integration(agent, request.clone()) + }) +} + +pub(super) fn uninstall(command: UninstallCommand) -> Result { + let target = command.host; + let request = command.into_runtime(); + let candidates = target.agents(); + let agents = if target.is_all() { + crate::agents::installed_integrations(&candidates, request.install_dir.as_deref()) + } else { + candidates + }; + if agents.is_empty() { + return Err(CliError::Install( + "no installed Claude Code, Codex, or Hermes integration state was found".into(), + )); + } + run_agent_operations(agents, "uninstall", |agent| { + crate::agents::uninstall_integration(agent, request.clone()) + }) +} + +pub(super) fn run_agent_operations( + agents: Vec, + operation: &str, + mut run: impl FnMut(CodingAgent) -> Result, +) -> Result { + let mut result = ExitCode::SUCCESS; + let mut errors = Vec::new(); + for agent in agents { + match run(agent) { + Ok(status) if status != ExitCode::SUCCESS => result = status, + Ok(_) => {} + Err(error) => errors.push(format!("{}: {error}", agent.as_arg())), + } + } + if errors.is_empty() { + Ok(result) + } else { + Err(CliError::Install(format!( + "failed to {operation} one or more integrations after attempting every target: {}", + errors.join("; ") + ))) + } +} diff --git a/crates/cli/src/commands/mcp.rs b/crates/cli/src/commands/mcp.rs new file mode 100644 index 000000000..9d132fc6c --- /dev/null +++ b/crates/cli/src/commands/mcp.rs @@ -0,0 +1,11 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use std::process::ExitCode; + +use super::serve::ServerArgs; +use crate::error::CliError; + +pub(super) async fn execute(server: &ServerArgs) -> Result { + crate::mcp::run(&server.to_runtime()).await +} diff --git a/crates/cli/src/commands/mod.rs b/crates/cli/src/commands/mod.rs new file mode 100644 index 000000000..20ceb1827 --- /dev/null +++ b/crates/cli/src/commands/mod.rs @@ -0,0 +1,151 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Command parsing, dispatch, rendering, and exit-code ownership. + +mod completions; +mod configure; +mod diagnostics; +mod hook_forward; +mod install; +mod mcp; +mod model_pricing; +mod plugins; +pub(crate) mod root; +mod run; +mod serve; + +use std::process::ExitCode; + +use clap::Parser; + +#[cfg(test)] +use self::completions::CompletionsCommand; +#[cfg(test)] +use self::model_pricing::PricingCommand; +#[cfg(test)] +use self::plugins::PluginsCommand; +use self::root::{Cli, Command}; +use self::serve::ServerArgs; +use crate::agents::CodingAgent; +use crate::{ + configuration as runtime_configuration, diagnostics as runtime_diagnostics, error, server, +}; + +// Runs the async CLI entrypoint and converts any surfaced gateway error into a non-zero process +// exit. Errors are printed once here so subcommands can return structured errors without also +// owning process-level reporting. +pub(crate) async fn run(bootstrap_shutdown_token: Option) -> ExitCode { + match dispatch(bootstrap_shutdown_token).await { + Ok(code) => code, + Err(error) => { + let exit_code = if error.guardrail_rejection_reason().is_some() { + ExitCode::from(2) + } else { + ExitCode::FAILURE + }; + eprintln!("{error}"); + exit_code + } + } +} + +// Dispatches CLI subcommands while keeping the no-subcommand path as server mode. `run` inherits +// top-level server flags so transparent launch can share config parsing with daemon startup. +async fn dispatch(bootstrap_shutdown_token: Option) -> Result { + let cli = Cli::parse(); + match cli.command { + Some(command) => run_command(command, &cli.server).await, + None => run_default(&cli.server, bootstrap_shutdown_token).await, + } +} + +async fn run_command(command: Command, server: &ServerArgs) -> Result { + match command { + Command::HookForward(command) => { + hook_forward::execute(command).await?; + Ok(ExitCode::SUCCESS) + } + Command::Install(command) => install::install(command), + Command::Uninstall(command) => install::uninstall(command), + Command::Run(command) => run::execute(command, server).await, + Command::Claude(command) => run::easy_path(CodingAgent::ClaudeCode, command, server).await, + Command::Codex(command) => run::easy_path(CodingAgent::Codex, command, server).await, + Command::Hermes(command) => run::easy_path(CodingAgent::Hermes, command, server).await, + Command::Mcp => mcp::execute(server).await, + Command::Config(command) => configure::execute(command).await, + Command::Plugins(command) => plugins::execute(command, server), + Command::ModelPricing(command) => model_pricing::execute(command), + Command::Doctor(command) => diagnostics::execute(command).await, + Command::Agents(command) => runtime_diagnostics::run_agents(command.json).await, + Command::Completions(command) => completions::execute(command), + } +} + +#[cfg(test)] +fn generate_completions_to( + shell: Option, + writer: &mut dyn std::io::Write, +) -> Result<(), error::CliError> { + completions::generate_to(shell, writer) +} + +async fn run_default( + server_args: &ServerArgs, + bootstrap_shutdown_token: Option, +) -> Result { + let runtime_args = server_args.to_runtime(); + // Bare `nemo-relay` with no subcommand: + // - If the user passed any daemon-specific flag (`--bind`, upstream URLs, ATIF dir, + // OpenInference endpoint), they obviously want the long-running gateway daemon — + // keep that path so existing scripts that explicitly invoke daemon mode stay + // compatible. + // - Otherwise — no flags, no subcommand — use the first-run path only when no config + // exists. Once configured, bare `nemo-relay` becomes a quick health check; explicit + // `nemo-relay config` remains the reconfiguration path. + if runtime_args.requested_daemon_mode() { + let resolved = runtime_configuration::resolve_server_config(&runtime_args)?; + let dynamic_plugins = crate::plugins::lifecycle::active_dynamic_plugin_components( + runtime_args.config.as_ref(), + &resolved, + )?; + let managed_bootstrap = runtime_configuration::managed_bootstrap_identity( + &runtime_args, + &resolved, + &dynamic_plugins, + )?; + server::serve_with_dynamic( + resolved.gateway, + dynamic_plugins, + managed_bootstrap, + runtime_args.ready_file.as_deref(), + bootstrap_shutdown_token, + ) + .await?; + Ok(ExitCode::SUCCESS) + } else if runtime_configuration::any_config_file_exists() { + runtime_diagnostics::run_doctor(None, false).await + } else { + configure::run(None).await?; + Ok(ExitCode::SUCCESS) + } +} + +#[cfg(test)] +fn run_completions(command: CompletionsCommand) -> Result { + completions::execute(command) +} + +#[cfg(test)] +fn run_plugins(command: PluginsCommand, server: &ServerArgs) -> Result { + plugins::execute(command, server) +} + +#[cfg(test)] +fn run_pricing(command: PricingCommand) -> Result { + model_pricing::execute(command) +} + +#[cfg(test)] +#[path = "../../tests/coverage/commands/main_tests.rs"] +mod tests; diff --git a/crates/cli/src/commands/model_pricing/mod.rs b/crates/cli/src/commands/model_pricing/mod.rs new file mode 100644 index 000000000..6ee8b5af0 --- /dev/null +++ b/crates/cli/src/commands/model_pricing/mod.rs @@ -0,0 +1,156 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use std::path::PathBuf; +use std::process::ExitCode; + +use clap::{ArgGroup, Args, Subcommand}; + +use crate::error::CliError; + +/// Args for `nemo-relay model-pricing`. +#[derive(Debug, Clone, Args)] +pub(crate) struct PricingCommand { + #[command(subcommand)] + pub(crate) command: PricingSubcommand, +} + +/// Model pricing catalog and resolver subcommands. +#[derive(Debug, Clone, Subcommand)] +pub(crate) enum PricingSubcommand { + /// Validate a model pricing catalog JSON file. + Validate(PricingValidateCommand), + /// Initialize model pricing in `plugins.toml`. + Init(PricingInitCommand), + /// Add a model pricing catalog file source to `plugins.toml`. + AddSource(PricingAddSourceCommand), + /// Resolve which model pricing entry matches a model and optional usage. + Resolve(PricingResolveCommand), +} + +/// Common target-scope flags for model pricing config mutations. +#[derive(Debug, Clone, Default, Args)] +#[command(group( + ArgGroup::new("pricing_scope") + .args(["user", "project", "global"]) + .multiple(false) +))] +pub(crate) struct PricingScopeArgs { + /// Edit the user config at `$XDG_CONFIG_HOME/nemo-relay/plugins.toml`. + #[arg(long)] + pub(crate) user: bool, + /// Edit the nearest project config at `.nemo-relay/plugins.toml`. + #[arg(long)] + pub(crate) project: bool, + /// Edit the system config at `/etc/nemo-relay/plugins.toml`. + #[arg(long)] + pub(crate) global: bool, +} + +/// Args for `nemo-relay model-pricing validate`. +#[derive(Debug, Clone, Args)] +pub(crate) struct PricingValidateCommand { + /// Path to a Relay model pricing catalog JSON file. + pub(crate) path: PathBuf, +} + +/// Args for `nemo-relay model-pricing init`. +#[derive(Debug, Clone, Args)] +pub(crate) struct PricingInitCommand { + #[command(flatten)] + pub(crate) scope: PricingScopeArgs, +} + +/// Args for `nemo-relay model-pricing add-source`. +#[derive(Debug, Clone, Args)] +pub(crate) struct PricingAddSourceCommand { + #[command(flatten)] + pub(crate) scope: PricingScopeArgs, + /// Path to a Relay model pricing catalog JSON file. + pub(crate) path: PathBuf, + /// Append as a lower-priority source instead of prepending as the highest-priority override. + #[arg(long)] + pub(crate) append: bool, +} + +/// Args for `nemo-relay model-pricing resolve`. +#[derive(Debug, Clone, Args)] +pub(crate) struct PricingResolveCommand { + /// Model ID or routed model name to look up. + pub(crate) model: String, + /// Optional provider or route, such as `openai`, `anthropic`, or `azure/openai`. + #[arg(long)] + pub(crate) provider: Option, + /// Prompt/input token count to use for an estimate. + #[arg(long)] + pub(crate) prompt_tokens: Option, + /// Completion/output token count to use for an estimate. + #[arg(long)] + pub(crate) completion_tokens: Option, + /// Prompt-cache read token count to use for an estimate. + #[arg(long)] + pub(crate) cache_read_tokens: Option, + /// Prompt-cache write token count to use for an estimate. + #[arg(long)] + pub(crate) cache_write_tokens: Option, +} +impl From for crate::plugins::ConfigurationScope { + fn from(value: PricingScopeArgs) -> Self { + match (value.user, value.project, value.global) { + (false, false, false) => Self::Default, + (true, false, false) => Self::User, + (false, true, false) => Self::Project, + (false, false, true) => Self::Global, + _ => Self::Invalid, + } + } +} +impl PricingValidateCommand { + pub(crate) fn into_runtime(self) -> crate::plugins::PricingValidateRequest { + crate::plugins::PricingValidateRequest { path: self.path } + } +} +impl PricingInitCommand { + pub(crate) fn into_runtime(self) -> crate::plugins::PricingInitRequest { + crate::plugins::PricingInitRequest { + scope: self.scope.into(), + } + } +} +impl PricingAddSourceCommand { + pub(crate) fn into_runtime(self) -> crate::plugins::PricingAddSourceRequest { + crate::plugins::PricingAddSourceRequest { + scope: self.scope.into(), + path: self.path, + append: self.append, + } + } +} +impl PricingResolveCommand { + pub(crate) fn into_runtime(self) -> crate::plugins::PricingResolveRequest { + crate::plugins::PricingResolveRequest { + model: self.model, + provider: self.provider, + prompt_tokens: self.prompt_tokens, + completion_tokens: self.completion_tokens, + cache_read_tokens: self.cache_read_tokens, + cache_write_tokens: self.cache_write_tokens, + } + } +} + +pub(super) fn execute(command: PricingCommand) -> Result { + match command.command { + PricingSubcommand::Validate(command) => { + crate::plugins::pricing::validate(command.into_runtime())? + } + PricingSubcommand::Init(command) => crate::plugins::pricing::init(command.into_runtime())?, + PricingSubcommand::AddSource(command) => { + crate::plugins::pricing::add_source(command.into_runtime())? + } + PricingSubcommand::Resolve(command) => { + crate::plugins::pricing::resolve(command.into_runtime())? + } + } + Ok(ExitCode::SUCCESS) +} diff --git a/crates/cli/src/commands/plugins/mod.rs b/crates/cli/src/commands/plugins/mod.rs new file mode 100644 index 000000000..c52d298d9 --- /dev/null +++ b/crates/cli/src/commands/plugins/mod.rs @@ -0,0 +1,69 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Dynamic-plugin command syntax, dispatch, and rendering. + +mod subcommands; + +pub(crate) use subcommands::PluginsCommand; +#[cfg(test)] +pub(crate) use subcommands::*; + +use std::process::ExitCode; + +use super::serve::ServerArgs; +use crate::error::CliError; + +pub(super) fn execute(command: PluginsCommand, server: &ServerArgs) -> Result { + let server = server.to_runtime(); + let json_context = command + .command + .json_context() + .map(|context| (context.command, context.target.map(str::to_owned))); + let json = json_context.is_some(); + let result = match command.command { + subcommands::PluginsSubcommand::Edit(command) => { + crate::plugins::edit(command.into_runtime()) + } + subcommands::PluginsSubcommand::Add(command) => { + crate::plugins::lifecycle::add(command.into_runtime(), &server) + } + subcommands::PluginsSubcommand::Validate(command) => { + crate::plugins::lifecycle::validate(command.into_runtime(), &server) + } + subcommands::PluginsSubcommand::List(command) => { + crate::plugins::lifecycle::list(command.into_runtime(), &server) + } + subcommands::PluginsSubcommand::Inspect(command) => { + crate::plugins::lifecycle::inspect(command.into_runtime(), &server) + } + subcommands::PluginsSubcommand::Enable(command) => { + crate::plugins::lifecycle::enable(command.into_runtime(), &server) + } + subcommands::PluginsSubcommand::Disable(command) => { + crate::plugins::lifecycle::disable(command.into_runtime(), &server) + } + subcommands::PluginsSubcommand::Remove(command) => { + crate::plugins::lifecycle::remove(command.into_runtime(), &server) + } + }; + match result { + Ok(()) => Ok(ExitCode::SUCCESS), + Err(error) => { + if let Some(exit_code) = crate::plugins::lifecycle::render_plugin_error(&error, json)? { + Ok(exit_code) + } else if json { + let (command, target) = json_context + .as_ref() + .expect("json plugin command context should exist when enabled"); + crate::plugins::lifecycle::render_generic_plugin_json_error( + command, + target.as_deref(), + &error.to_string(), + ) + } else { + Err(error) + } + } + } +} diff --git a/crates/cli/src/commands/plugins/subcommands.rs b/crates/cli/src/commands/plugins/subcommands.rs new file mode 100644 index 000000000..c9cbfbd3a --- /dev/null +++ b/crates/cli/src/commands/plugins/subcommands.rs @@ -0,0 +1,213 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use clap::{ArgGroup, Args, Subcommand}; +use std::path::PathBuf; + +/// Args for `nemo-relay plugins`. +#[derive(Debug, Clone, Args)] +pub(crate) struct PluginsCommand { + #[command(subcommand)] + pub(crate) command: PluginsSubcommand, +} + +#[derive(Debug, Clone, Copy)] +pub(crate) struct PluginJsonContext<'a> { + pub(crate) command: &'static str, + pub(crate) target: Option<&'a str>, +} + +/// Plugin configuration subcommands. +#[derive(Debug, Clone, Subcommand)] +pub(crate) enum PluginsSubcommand { + /// Interactively create or edit built-in and dynamic plugin configuration. + Edit(PluginsEditCommand), + /// Register a manifest-backed dynamic plugin in `plugins.toml`. + Add(PluginsAddCommand), + /// Validate a manifest-backed dynamic plugin by path or installed ID. + Validate(PluginsValidateCommand), + /// List discovered dynamic plugins from the resolved host config. + List(PluginsListCommand), + /// Inspect one discovered dynamic plugin by canonical ID. + Inspect(PluginsInspectCommand), + /// Mark a registered dynamic plugin enabled in desired state. + Enable(PluginsEnableCommand), + /// Mark a registered dynamic plugin disabled in desired state. + Disable(PluginsDisableCommand), + /// Tombstone a registered dynamic plugin and remove its host discovery reference. + Remove(PluginsRemoveCommand), +} + +impl PluginsSubcommand { + pub(crate) fn json_context(&self) -> Option> { + match self { + Self::Validate(command) if command.json => Some(PluginJsonContext { + command: "plugins validate", + target: Some(command.target.as_str()), + }), + Self::List(command) if command.json => Some(PluginJsonContext { + command: "plugins list", + target: None, + }), + Self::Inspect(command) if command.json => Some(PluginJsonContext { + command: "plugins inspect", + target: Some(command.id.as_str()), + }), + _ => None, + } + } +} + +/// Args for `nemo-relay plugins edit`. +#[derive(Debug, Clone, Default, Args)] +#[command(group( + ArgGroup::new("scope") + .args(["user", "project", "global"]) + .multiple(false) +))] +pub(crate) struct PluginsScopeArgs { + /// Edit the user config at `$XDG_CONFIG_HOME/nemo-relay/plugins.toml`. + #[arg(long)] + pub(crate) user: bool, + /// Edit the nearest project config at `.nemo-relay/plugins.toml`. + #[arg(long)] + pub(crate) project: bool, + /// Edit the system config at `/etc/nemo-relay/plugins.toml`. + #[arg(long)] + pub(crate) global: bool, +} + +/// Args for `nemo-relay plugins edit`. +#[derive(Debug, Clone, Default, Args)] +pub(crate) struct PluginsEditCommand { + #[command(flatten)] + pub(crate) scope: PluginsScopeArgs, +} + +/// Args for `nemo-relay plugins add`. +#[derive(Debug, Clone, Default, Args)] +pub(crate) struct PluginsAddCommand { + #[command(flatten)] + pub(crate) scope: PluginsScopeArgs, + /// Path to a plugin directory or explicit `relay-plugin.toml`. + pub(crate) path: PathBuf, +} + +/// Args for `nemo-relay plugins validate`. +#[derive(Debug, Clone, Args)] +pub(crate) struct PluginsValidateCommand { + /// Canonical plugin ID or a local plugin directory / `relay-plugin.toml` path. + pub(crate) target: String, + /// Emit machine-readable JSON output. + #[arg(long)] + pub(crate) json: bool, +} + +/// Args for `nemo-relay plugins list`. +#[derive(Debug, Clone, Default, Args)] +pub(crate) struct PluginsListCommand { + /// Include tombstoned dynamic plugin records in the output. + #[arg(long)] + pub(crate) all: bool, + /// Emit machine-readable JSON output. + #[arg(long)] + pub(crate) json: bool, +} + +/// Args for `nemo-relay plugins inspect`. +#[derive(Debug, Clone, Args)] +pub(crate) struct PluginsInspectCommand { + /// Canonical plugin ID. + pub(crate) id: String, + /// Emit machine-readable JSON output. + #[arg(long)] + pub(crate) json: bool, +} + +/// Args for `nemo-relay plugins enable`. +#[derive(Debug, Clone, Args)] +pub(crate) struct PluginsEnableCommand { + /// Canonical plugin ID. + pub(crate) id: String, +} + +/// Args for `nemo-relay plugins disable`. +#[derive(Debug, Clone, Args)] +pub(crate) struct PluginsDisableCommand { + /// Canonical plugin ID. + pub(crate) id: String, +} + +/// Args for `nemo-relay plugins remove`. +#[derive(Debug, Clone, Args)] +pub(crate) struct PluginsRemoveCommand { + /// Canonical plugin ID. + pub(crate) id: String, +} + +impl From for crate::plugins::ConfigurationScope { + fn from(value: PluginsScopeArgs) -> Self { + match (value.user, value.project, value.global) { + (false, false, false) => Self::Default, + (true, false, false) => Self::User, + (false, true, false) => Self::Project, + (false, false, true) => Self::Global, + _ => Self::Invalid, + } + } +} + +impl PluginsEditCommand { + pub(crate) fn into_runtime(self) -> crate::plugins::PluginsEditRequest { + crate::plugins::PluginsEditRequest { + scope: self.scope.into(), + } + } +} +impl PluginsAddCommand { + pub(crate) fn into_runtime(self) -> crate::plugins::PluginsAddRequest { + crate::plugins::PluginsAddRequest { + scope: self.scope.into(), + path: self.path, + } + } +} +impl PluginsValidateCommand { + pub(crate) fn into_runtime(self) -> crate::plugins::PluginsValidateRequest { + crate::plugins::PluginsValidateRequest { + target: self.target, + json: self.json, + } + } +} +impl PluginsListCommand { + pub(crate) fn into_runtime(self) -> crate::plugins::PluginsListRequest { + crate::plugins::PluginsListRequest { + all: self.all, + json: self.json, + } + } +} +impl PluginsInspectCommand { + pub(crate) fn into_runtime(self) -> crate::plugins::PluginsInspectRequest { + crate::plugins::PluginsInspectRequest { + id: self.id, + json: self.json, + } + } +} +impl PluginsEnableCommand { + pub(crate) fn into_runtime(self) -> crate::plugins::PluginsEnableRequest { + crate::plugins::PluginsEnableRequest { id: self.id } + } +} +impl PluginsDisableCommand { + pub(crate) fn into_runtime(self) -> crate::plugins::PluginsDisableRequest { + crate::plugins::PluginsDisableRequest { id: self.id } + } +} +impl PluginsRemoveCommand { + pub(crate) fn into_runtime(self) -> crate::plugins::PluginsRemoveRequest { + crate::plugins::PluginsRemoveRequest { id: self.id } + } +} diff --git a/crates/cli/src/commands/root.rs b/crates/cli/src/commands/root.rs new file mode 100644 index 000000000..1f41dca1a --- /dev/null +++ b/crates/cli/src/commands/root.rs @@ -0,0 +1,123 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use clap::{Parser, Subcommand, ValueEnum}; + +use super::completions::CompletionsCommand; +use super::configure::ConfigCommand; +use super::diagnostics::{AgentsCommand, DoctorCommand}; +use super::hook_forward::HookForwardCommand; +use super::install::{InstallCommand, UninstallCommand}; +use super::model_pricing::PricingCommand; +use super::plugins::PluginsCommand; +use super::run::{EasyPathCommand, RunCommand}; +use super::serve::ServerArgs; +use crate::agents::CodingAgent; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] +#[value(rename_all = "kebab-case")] +pub(crate) enum AgentArg { + #[value(name = "claude", alias = "claude-code")] + Claude, + Codex, + Hermes, +} + +impl From for CodingAgent { + fn from(value: AgentArg) -> Self { + match value { + AgentArg::Claude => Self::ClaudeCode, + AgentArg::Codex => Self::Codex, + AgentArg::Hermes => Self::Hermes, + } + } +} + +#[derive(Debug, Clone, Parser)] +#[command(name = "nemo-relay")] +#[command(about = "Coding-agent gateway for NeMo Relay observability")] +#[command(version)] +pub(crate) struct Cli { + #[command(flatten)] + pub(crate) server: ServerArgs, + #[command(subcommand)] + pub(crate) command: Option, +} + +#[derive(Debug, Clone, Subcommand)] +pub(crate) enum Command { + /// Run Claude Code with observability (setup on first use) + #[command( + long_about = "Run Anthropic's `claude` CLI under an ephemeral NeMo Relay gateway. \ + Observability (ATIF + OpenInference) is wired in transparently via \ + ANTHROPIC_BASE_URL. First-time use launches the setup wizard so the \ + `[agents.claude]` block lands in `.nemo-relay/config.toml` and observation \ + starts on the next invocation without prompts.", + after_help = "Examples:\n \ + nemo-relay claude\n \ + nemo-relay claude -- chat \"refactor the launcher\"\n \ + nemo-relay claude -- --resume " + )] + Claude(EasyPathCommand), + /// Run Codex with observability (setup on first use) + #[command( + long_about = "Run OpenAI's `codex` CLI under an ephemeral NeMo Relay gateway. NeMo Relay \ + injects a `nemo-relay-openai` provider override so codex points at the \ + gateway; the gateway then forwards to `--openai-base-url` (defaults to \ + api.openai.com) with `OPENAI_API_KEY` injected on the codex route (see \ + NMF-86 — codex's own auth.json JWT is stripped). The supported host version \ + is validated before launch.", + after_help = "Examples:\n \ + nemo-relay codex\n \ + nemo-relay codex -- exec \"fix the bug in foo.rs\"\n \ + nemo-relay --openai-base-url https://inference-api.nvidia.com codex" + )] + Codex(EasyPathCommand), + /// Run Hermes with observability (setup on first use) + #[command( + long_about = "Run Hermes Agent under an ephemeral NeMo Relay gateway. The wrapper uses a \ + process-private HERMES_HOME overlay for dynamic hooks, without rewriting \ + the user's Hermes configuration. Use `nemo-relay install hermes` when bare \ + Hermes processes should load the shared native Relay gateway on \ + 127.0.0.1:47632 through MCP.", + after_help = "Examples:\n \ + nemo-relay hermes\n \ + nemo-relay hermes -- chat --provider custom" + )] + Hermes(EasyPathCommand), + /// Keep a shared Relay gateway ready for an MCP client. + #[command( + long_about = "Start or reuse a shared native NeMo Relay gateway for an MCP stdio \ + connection. The command acquires the gateway immediately, before reading \ + MCP protocol frames. The gateway binds 127.0.0.1:47632 by default and MCP \ + initialization completes only after Relay identity and readiness are \ + verified. Multiple MCP clients share the gateway; it remains available \ + until its idle timeout after the final client closes. This command \ + advertises no MCP tools.", + after_help = "Examples:\n \ + nemo-relay mcp\n \ + nemo-relay --bind 127.0.0.1:4041 mcp # explicit standalone/test bind" + )] + Mcp, + /// Run the interactive setup (writes `.nemo-relay/config.toml`) + Config(ConfigCommand), + /// Create or edit plugin configuration (writes `plugins.toml`) + Plugins(PluginsCommand), + /// Install coding-agent plugins from the local nemo-relay CLI. + Install(InstallCommand), + /// Uninstall coding-agent plugins installed by `nemo-relay install`. + Uninstall(UninstallCommand), + /// Validate and configure model pricing catalogs. + ModelPricing(PricingCommand), + /// Diagnose env, agents, config, observability (optionally scoped to one agent) + Doctor(DoctorCommand), + /// List supported and locally-detected agents (use `--json` for machine output) + Agents(AgentsCommand), + /// Print shell completion script (e.g. `nemo-relay completions zsh > ~/.zfunc/_nemo-relay`) + Completions(CompletionsCommand), + /// Run an agent deterministically (no wizard; errors if config is missing) + Run(RunCommand), + /// Internal: subprocess used by installed hooks to forward events. Not typed by humans. + #[command(hide = true)] + HookForward(HookForwardCommand), +} diff --git a/crates/cli/src/commands/run.rs b/crates/cli/src/commands/run.rs new file mode 100644 index 000000000..8edbca27b --- /dev/null +++ b/crates/cli/src/commands/run.rs @@ -0,0 +1,93 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use std::path::PathBuf; +use std::process::ExitCode; + +use clap::Args; + +use super::root::AgentArg; +use super::serve::ServerArgs; +use crate::agents::CodingAgent; +use crate::error::CliError; + +/// Args for an easy-path agent shortcut. +#[derive(Debug, Clone, Args)] +pub(crate) struct EasyPathCommand { + #[arg(last = true)] + pub(super) command: Vec, +} + +#[derive(Debug, Clone, Args)] +pub(crate) struct RunCommand { + #[arg(long, value_enum)] + pub(super) agent: Option, + #[arg(long)] + pub(super) config: Option, + #[arg(long)] + pub(super) openai_base_url: Option, + #[arg(long)] + pub(super) anthropic_base_url: Option, + #[arg(long)] + pub(super) session_metadata: Option, + #[arg(long, env = "NEMO_RELAY_PLUGIN_CONFIG_PATH", hide = true)] + pub(super) plugin_config_path: Option, + #[arg(long)] + pub(super) dry_run: bool, + #[arg(long)] + pub(super) print: bool, + #[arg(last = true)] + pub(super) command: Vec, +} + +impl RunCommand { + fn into_runtime(self) -> crate::process::RunOverrides { + crate::process::RunOverrides { + agent: self.agent.map(Into::into), + config: self.config, + openai_base_url: self.openai_base_url, + anthropic_base_url: self.anthropic_base_url, + session_metadata: self.session_metadata, + plugin_config_path: self.plugin_config_path, + dry_run: self.dry_run, + print: self.print, + command: self.command, + } + } +} + +pub(super) async fn execute( + command: RunCommand, + server: &ServerArgs, +) -> Result { + let inherited = server.to_runtime(); + crate::process::launcher::run(command.into_runtime(), Some(&inherited)).await +} + +pub(super) async fn easy_path( + agent: CodingAgent, + command: EasyPathCommand, + server: &ServerArgs, +) -> Result { + let inherited = server.to_runtime(); + // An explicit config path is the user's contract. Without one, setup is required only when + // none of the normal discovery layers exists. Keep this interactive decision in the command + // layer so process supervision receives a complete, agent-neutral run request. + let explicit_config = inherited.config.as_deref(); + let needs_setup = explicit_config.is_none() && !crate::configuration::any_config_file_exists(); + if needs_setup { + super::configure::run(Some(agent)).await?; + } + let runtime = crate::process::RunOverrides { + agent: Some(agent), + config: explicit_config.map(PathBuf::from), + openai_base_url: None, + anthropic_base_url: None, + session_metadata: None, + plugin_config_path: None, + dry_run: false, + print: false, + command: command.command, + }; + crate::process::launcher::run(runtime, Some(&inherited)).await +} diff --git a/crates/cli/src/commands/serve.rs b/crates/cli/src/commands/serve.rs new file mode 100644 index 000000000..3b83fff9e --- /dev/null +++ b/crates/cli/src/commands/serve.rs @@ -0,0 +1,50 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use std::net::SocketAddr; +use std::path::PathBuf; + +use clap::Args; + +#[derive(Debug, Clone, Default, Args)] +pub(crate) struct ServerArgs { + /// Path to an explicit config file (disables auto-discovery of workspace/global/system) + #[arg(long)] + pub(super) config: Option, + /// Address for the gateway to listen on in daemon mode (default 127.0.0.1:4040) + #[arg(long, env = "NEMO_RELAY_GATEWAY_BIND")] + pub(super) bind: Option, + /// Upstream OpenAI-compatible base URL (e.g. https://api.openai.com/v1, NVIDIA inference) + #[arg(long, env = "NEMO_RELAY_OPENAI_BASE_URL")] + pub(super) openai_base_url: Option, + /// Upstream Anthropic base URL (e.g. https://api.anthropic.com) + #[arg(long, env = "NEMO_RELAY_ANTHROPIC_BASE_URL")] + pub(super) anthropic_base_url: Option, + /// Internal override for the plugin configuration file. + #[arg(long, env = "NEMO_RELAY_PLUGIN_CONFIG_PATH", hide = true)] + pub(super) plugin_config_path: Option, + /// Internal readiness file used by plugin sidecar bootstrap. + #[arg(long, hide = true)] + pub(super) ready_file: Option, + /// Maximum accepted coding-agent hook payload size, in bytes. + #[arg(long, env = "NEMO_RELAY_MAX_HOOK_PAYLOAD_BYTES")] + pub(super) max_hook_payload_bytes: Option, + /// Maximum accepted provider passthrough request body size, in bytes. + #[arg(long, env = "NEMO_RELAY_MAX_PASSTHROUGH_BODY_BYTES")] + pub(super) max_passthrough_body_bytes: Option, +} + +impl ServerArgs { + pub(super) fn to_runtime(&self) -> crate::server::GatewayOverrides { + crate::server::GatewayOverrides { + config: self.config.clone(), + bind: self.bind, + openai_base_url: self.openai_base_url.clone(), + anthropic_base_url: self.anthropic_base_url.clone(), + plugin_config_path: self.plugin_config_path.clone(), + ready_file: self.ready_file.clone(), + max_hook_payload_bytes: self.max_hook_payload_bytes, + max_passthrough_body_bytes: self.max_passthrough_body_bytes, + } + } +} diff --git a/crates/cli/src/config.rs b/crates/cli/src/config.rs deleted file mode 100644 index d4491d6f6..000000000 --- a/crates/cli/src/config.rs +++ /dev/null @@ -1,1398 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -use std::collections::HashSet; -use std::net::SocketAddr; -use std::path::{Path, PathBuf}; - -use axum::http::HeaderMap; -use clap::{ArgGroup, Args, Parser, Subcommand, ValueEnum}; -use nemo_relay::plugin::dynamic::DynamicPluginManifest; -use nemo_relay::plugin::{PluginError, merge_plugin_config_documents}; -use serde::{Deserialize, Serialize}; -use serde_json::{Map, Value}; -use strum::{Display, IntoStaticStr}; - -use crate::error::CliError; -use crate::plugin_shim::PluginShimCommand; -use crate::plugins::lifecycle::enforce_required_dynamic_plugin_startup; -use crate::plugins::policy::DynamicPluginHostPolicy; - -#[derive(Debug, Clone, Parser)] -#[command(name = "nemo-relay")] -#[command(about = "Coding-agent gateway for NeMo Relay observability")] -#[command(version)] -pub(crate) struct Cli { - #[command(flatten)] - pub(crate) server: ServerArgs, - #[command(subcommand)] - pub(crate) command: Option, -} - -#[derive(Debug, Clone, Subcommand)] -pub(crate) enum Command { - /// Run Claude Code with observability (setup on first use) - #[command( - long_about = "Run Anthropic's `claude` CLI under an ephemeral NeMo Relay gateway. \ - Observability (ATIF + OpenInference) is wired in transparently via \ - ANTHROPIC_BASE_URL. First-time use launches the setup wizard so the \ - `[agents.claude]` block lands in `.nemo-relay/config.toml` and observation \ - starts on the next invocation without prompts.", - after_help = "Examples:\n \ - nemo-relay claude\n \ - nemo-relay claude -- chat \"refactor the launcher\"\n \ - nemo-relay claude -- --resume " - )] - Claude(EasyPathCommand), - /// Run Codex with observability (setup on first use) - #[command( - long_about = "Run OpenAI's `codex` CLI under an ephemeral NeMo Relay gateway. NeMo Relay \ - injects a `nemo-relay-openai` provider override so codex points at the \ - gateway; the gateway then forwards to `--openai-base-url` (defaults to \ - api.openai.com) with `OPENAI_API_KEY` injected on the codex route (see \ - NMF-86 — codex's own auth.json JWT is stripped). Requires codex-cli >= \ - 0.129.0.", - after_help = "Examples:\n \ - nemo-relay codex\n \ - nemo-relay codex -- exec \"fix the bug in foo.rs\"\n \ - nemo-relay --openai-base-url https://inference-api.nvidia.com codex" - )] - Codex(EasyPathCommand), - /// Run Hermes with observability (setup on first use) - #[command( - long_about = "Run NVIDIA's Hermes agent under a NeMo Relay gateway. Hermes reads hooks \ - from `.hermes/config.yaml`; first-run setup writes that file alongside \ - `.nemo-relay/config.toml` so every subsequent invocation traces \ - automatically. Re-run `nemo-relay config hermes` to refresh the hooks.", - after_help = "Examples:\n \ - nemo-relay hermes\n \ - nemo-relay hermes -- chat --provider custom" - )] - Hermes(EasyPathCommand), - /// Run the interactive setup (writes `.nemo-relay/config.toml`) - Config(ConfigCommand), - /// Create or edit plugin configuration (writes `plugins.toml`) - Plugins(PluginsCommand), - /// Install coding-agent plugins from the local nemo-relay CLI. - Install(InstallCommand), - /// Uninstall coding-agent plugins installed by `nemo-relay install`. - Uninstall(UninstallCommand), - /// Validate and configure model pricing catalogs. - ModelPricing(PricingCommand), - /// Diagnose env, agents, config, observability (optionally scoped to one agent) - Doctor(DoctorCommand), - /// List supported and locally-detected agents (use `--json` for machine output) - Agents(AgentsCommand), - /// Print shell completion script (e.g. `nemo-relay completions zsh > ~/.zfunc/_nemo-relay`) - Completions(CompletionsCommand), - /// Run an agent deterministically (no wizard; errors if config is missing) - Run(RunCommand), - /// Internal: subprocess used by installed hooks to forward events. Not typed by humans. - #[command(hide = true)] - HookForward(HookForwardCommand), - /// Internal: plugin-local hook and sidecar supervisor. Not typed by humans. - #[command(hide = true)] - PluginShim(PluginShimCommand), -} - -/// Args for `nemo-relay doctor`. `--json` is on this command (rather than as a global flag) -/// so it doesn't pollute the help output of subcommands where it has no meaning. -#[derive(Debug, Clone, Args)] -pub(crate) struct DoctorCommand { - /// Limit readiness checks to one supported agent. - #[arg(value_enum)] - pub(crate) agent: Option, - /// Diagnose an installed coding-agent plugin instead of the normal relay config. - #[arg(long, value_enum)] - pub(crate) plugin: Option, - /// Plugin install state directory. Defaults to the platform data directory. - #[arg(long)] - pub(crate) install_dir: Option, - /// Emit machine-readable JSON instead of the formatted human report. Versioned via - /// `schema_version`; stable shape for CI / evaluation harness consumption. - #[arg(long)] - pub(crate) json: bool, -} - -#[derive(Debug, Clone, Args)] -pub(crate) struct InstallCommand { - #[arg(value_enum)] - pub(crate) host: PluginHost, - #[arg(long)] - pub(crate) install_dir: Option, - #[arg(long)] - pub(crate) force: bool, - #[arg(long)] - pub(crate) dry_run: bool, - #[arg(long)] - pub(crate) skip_doctor: bool, -} - -#[derive(Debug, Clone, Args)] -pub(crate) struct UninstallCommand { - #[arg(value_enum)] - pub(crate) host: PluginHost, - #[arg(long)] - pub(crate) install_dir: Option, - #[arg(long)] - pub(crate) dry_run: bool, -} - -/// Args for `nemo-relay agents`. Shares the `--json` shape with `nemo-relay doctor`'s -/// `agents` field so the two outputs can be unified by downstream consumers. -#[derive(Debug, Clone, Args)] -pub(crate) struct AgentsCommand { - /// Emit the supported + detected agent list as JSON instead of formatted text. - #[arg(long)] - pub(crate) json: bool, -} - -/// Args for `nemo-relay completions ` (print to stdout) or `nemo-relay completions --install` -/// (auto-detect $SHELL and write to the standard fpath / completions directory). -/// -/// The Homebrew / curl-install flows drop completion scripts automatically; this subcommand is -/// the escape hatch for CI, custom shells, regeneration, and `cargo install` users where no -/// post-install hook runs. -#[derive(Debug, Clone, Args)] -pub(crate) struct CompletionsCommand { - /// Shell to generate the completion script for. Optional when used with `--install` (the - /// installer auto-detects `$SHELL`). - #[arg(value_enum)] - pub(crate) shell: Option, - /// Write the completion script into the shell's standard completions directory instead of - /// printing to stdout. Auto-detects `$SHELL` when no shell argument is given. - #[arg(long)] - pub(crate) install: bool, -} - -/// Args for `nemo-relay config`. The setup wizard runs by default; `--reset` short-circuits to -/// a destructive clear. An optional positional agent name scopes both the wizard and `--reset` -/// to a single agent's settings, leaving other agents' blocks untouched. -#[derive(Debug, Clone, Args)] -pub(crate) struct ConfigCommand { - /// Scope this run to one agent. Wizard skips the agent multi-select; `--reset` removes - /// only that agent's block from the existing config file. Omit to operate on all agents. - #[arg(value_enum)] - pub(crate) agent: Option, - /// Delete the project config file (or remove just the scoped agent's block when an agent - /// is named). The wizard does NOT run after a reset — invoke `nemo-relay config` again to - /// re-create the file from scratch. - #[arg(long)] - pub(crate) reset: bool, -} - -/// Args for `nemo-relay plugins`. -#[derive(Debug, Clone, Args)] -pub(crate) struct PluginsCommand { - #[command(subcommand)] - pub(crate) command: PluginsSubcommand, -} - -#[derive(Debug, Clone, Copy)] -pub(crate) struct PluginJsonContext<'a> { - pub(crate) command: &'static str, - pub(crate) target: Option<&'a str>, -} - -/// Plugin configuration subcommands. -#[derive(Debug, Clone, Subcommand)] -pub(crate) enum PluginsSubcommand { - /// Interactively create or edit built-in and dynamic plugin configuration. - Edit(PluginsEditCommand), - /// Register a manifest-backed dynamic plugin in `plugins.toml`. - Add(PluginsAddCommand), - /// Validate a manifest-backed dynamic plugin by path or installed ID. - Validate(PluginsValidateCommand), - /// List discovered dynamic plugins from the resolved host config. - List(PluginsListCommand), - /// Inspect one discovered dynamic plugin by canonical ID. - Inspect(PluginsInspectCommand), - /// Mark a registered dynamic plugin enabled in desired state. - Enable(PluginsEnableCommand), - /// Mark a registered dynamic plugin disabled in desired state. - Disable(PluginsDisableCommand), - /// Tombstone a registered dynamic plugin and remove its host discovery reference. - Remove(PluginsRemoveCommand), -} - -impl PluginsSubcommand { - pub(crate) fn json_context(&self) -> Option> { - match self { - Self::Validate(command) if command.json => Some(PluginJsonContext { - command: "plugins validate", - target: Some(command.target.as_str()), - }), - Self::List(command) if command.json => Some(PluginJsonContext { - command: "plugins list", - target: None, - }), - Self::Inspect(command) if command.json => Some(PluginJsonContext { - command: "plugins inspect", - target: Some(command.id.as_str()), - }), - _ => None, - } - } -} - -/// Args for `nemo-relay model-pricing`. -#[derive(Debug, Clone, Args)] -pub(crate) struct PricingCommand { - #[command(subcommand)] - pub(crate) command: PricingSubcommand, -} - -/// Model pricing catalog and resolver subcommands. -#[derive(Debug, Clone, Subcommand)] -pub(crate) enum PricingSubcommand { - /// Validate a model pricing catalog JSON file. - Validate(PricingValidateCommand), - /// Initialize model pricing in `plugins.toml`. - Init(PricingInitCommand), - /// Add a model pricing catalog file source to `plugins.toml`. - AddSource(PricingAddSourceCommand), - /// Resolve which model pricing entry matches a model and optional usage. - Resolve(PricingResolveCommand), -} - -/// Common target-scope flags for model pricing config mutations. -#[derive(Debug, Clone, Default, Args)] -#[command(group( - ArgGroup::new("pricing_scope") - .args(["user", "project", "global"]) - .multiple(false) -))] -pub(crate) struct PricingScopeArgs { - /// Edit the user config at `$XDG_CONFIG_HOME/nemo-relay/plugins.toml`. - #[arg(long)] - pub(crate) user: bool, - /// Edit the nearest project config at `.nemo-relay/plugins.toml`. - #[arg(long)] - pub(crate) project: bool, - /// Edit the system config at `/etc/nemo-relay/plugins.toml`. - #[arg(long)] - pub(crate) global: bool, -} - -/// Args for `nemo-relay model-pricing validate`. -#[derive(Debug, Clone, Args)] -pub(crate) struct PricingValidateCommand { - /// Path to a Relay model pricing catalog JSON file. - pub(crate) path: PathBuf, -} - -/// Args for `nemo-relay model-pricing init`. -#[derive(Debug, Clone, Args)] -pub(crate) struct PricingInitCommand { - #[command(flatten)] - pub(crate) scope: PricingScopeArgs, -} - -/// Args for `nemo-relay model-pricing add-source`. -#[derive(Debug, Clone, Args)] -pub(crate) struct PricingAddSourceCommand { - #[command(flatten)] - pub(crate) scope: PricingScopeArgs, - /// Path to a Relay model pricing catalog JSON file. - pub(crate) path: PathBuf, - /// Append as a lower-priority source instead of prepending as the highest-priority override. - #[arg(long)] - pub(crate) append: bool, -} - -/// Args for `nemo-relay model-pricing resolve`. -#[derive(Debug, Clone, Args)] -pub(crate) struct PricingResolveCommand { - /// Model ID or routed model name to look up. - pub(crate) model: String, - /// Optional provider or route, such as `openai`, `anthropic`, or `azure/openai`. - #[arg(long)] - pub(crate) provider: Option, - /// Prompt/input token count to use for an estimate. - #[arg(long)] - pub(crate) prompt_tokens: Option, - /// Completion/output token count to use for an estimate. - #[arg(long)] - pub(crate) completion_tokens: Option, - /// Prompt-cache read token count to use for an estimate. - #[arg(long)] - pub(crate) cache_read_tokens: Option, - /// Prompt-cache write token count to use for an estimate. - #[arg(long)] - pub(crate) cache_write_tokens: Option, -} - -/// Args for `nemo-relay plugins edit`. -#[derive(Debug, Clone, Default, Args)] -#[command(group( - ArgGroup::new("scope") - .args(["user", "project", "global"]) - .multiple(false) -))] -pub(crate) struct PluginsScopeArgs { - /// Edit the user config at `$XDG_CONFIG_HOME/nemo-relay/plugins.toml`. - #[arg(long)] - pub(crate) user: bool, - /// Edit the nearest project config at `.nemo-relay/plugins.toml`. - #[arg(long)] - pub(crate) project: bool, - /// Edit the system config at `/etc/nemo-relay/plugins.toml`. - #[arg(long)] - pub(crate) global: bool, -} - -/// Args for `nemo-relay plugins edit`. -#[derive(Debug, Clone, Default, Args)] -pub(crate) struct PluginsEditCommand { - #[command(flatten)] - pub(crate) scope: PluginsScopeArgs, -} - -/// Args for `nemo-relay plugins add`. -#[derive(Debug, Clone, Default, Args)] -pub(crate) struct PluginsAddCommand { - #[command(flatten)] - pub(crate) scope: PluginsScopeArgs, - /// Path to a plugin directory or explicit `relay-plugin.toml`. - pub(crate) path: PathBuf, -} - -/// Args for `nemo-relay plugins validate`. -#[derive(Debug, Clone, Args)] -pub(crate) struct PluginsValidateCommand { - /// Canonical plugin ID or a local plugin directory / `relay-plugin.toml` path. - pub(crate) target: String, - /// Emit machine-readable JSON output. - #[arg(long)] - pub(crate) json: bool, -} - -/// Args for `nemo-relay plugins list`. -#[derive(Debug, Clone, Default, Args)] -pub(crate) struct PluginsListCommand { - /// Include tombstoned dynamic plugin records in the output. - #[arg(long)] - pub(crate) all: bool, - /// Emit machine-readable JSON output. - #[arg(long)] - pub(crate) json: bool, -} - -/// Args for `nemo-relay plugins inspect`. -#[derive(Debug, Clone, Args)] -pub(crate) struct PluginsInspectCommand { - /// Canonical plugin ID. - pub(crate) id: String, - /// Emit machine-readable JSON output. - #[arg(long)] - pub(crate) json: bool, -} - -/// Args for `nemo-relay plugins enable`. -#[derive(Debug, Clone, Args)] -pub(crate) struct PluginsEnableCommand { - /// Canonical plugin ID. - pub(crate) id: String, -} - -/// Args for `nemo-relay plugins disable`. -#[derive(Debug, Clone, Args)] -pub(crate) struct PluginsDisableCommand { - /// Canonical plugin ID. - pub(crate) id: String, -} - -/// Args for `nemo-relay plugins remove`. -#[derive(Debug, Clone, Args)] -pub(crate) struct PluginsRemoveCommand { - /// Canonical plugin ID. - pub(crate) id: String, -} - -#[derive(Debug, Clone, Default, Args)] -pub(crate) struct ServerArgs { - /// Path to an explicit config file (disables auto-discovery of workspace/global/system) - #[arg(long)] - pub(crate) config: Option, - /// Address for the gateway to listen on in daemon mode (default 127.0.0.1:4040) - #[arg(long, env = "NEMO_RELAY_GATEWAY_BIND")] - pub(crate) bind: Option, - /// Upstream OpenAI-compatible base URL (e.g. https://api.openai.com/v1, NVIDIA inference) - #[arg(long, env = "NEMO_RELAY_OPENAI_BASE_URL")] - pub(crate) openai_base_url: Option, - /// Upstream Anthropic base URL (e.g. https://api.anthropic.com) - #[arg(long, env = "NEMO_RELAY_ANTHROPIC_BASE_URL")] - pub(crate) anthropic_base_url: Option, - /// Internal override for the plugin configuration file. - #[arg(long, env = "NEMO_RELAY_PLUGIN_CONFIG_PATH", hide = true)] - pub(crate) plugin_config_path: Option, - /// Maximum accepted coding-agent hook payload size, in bytes. - #[arg(long, env = "NEMO_RELAY_MAX_HOOK_PAYLOAD_BYTES")] - pub(crate) max_hook_payload_bytes: Option, - /// Maximum accepted provider passthrough request body size, in bytes. - #[arg(long, env = "NEMO_RELAY_MAX_PASSTHROUGH_BODY_BYTES")] - pub(crate) max_passthrough_body_bytes: Option, -} - -impl ServerArgs { - /// True when the user passed any flag that signals "I want the gateway, not the wizard." Used - /// by the bare `nemo-relay` dispatch to choose between launching the long-running daemon and - /// dropping into setup. `--config` is included: someone running `nemo-relay --config ` - /// with no subcommand has explicitly pointed at a config file, which is only meaningful for - /// daemon startup — the wizard creates configs, it doesn't consume them. - pub(crate) fn requested_daemon_mode(&self) -> bool { - self.bind.is_some() - || self.openai_base_url.is_some() - || self.anthropic_base_url.is_some() - || self.plugin_config_path.is_some() - || self.max_hook_payload_bytes.is_some() - || self.max_passthrough_body_bytes.is_some() - || self.config.is_some() - } -} - -pub(crate) const DEFAULT_MAX_HOOK_PAYLOAD_BYTES: usize = 20 * 1024 * 1024; -pub(crate) const DEFAULT_MAX_PASSTHROUGH_BODY_BYTES: usize = 100 * 1024 * 1024; - -#[derive(Debug, Clone)] -pub(crate) struct GatewayConfig { - pub(crate) bind: SocketAddr, - pub(crate) openai_base_url: String, - pub(crate) anthropic_base_url: String, - pub(crate) metadata: Option, - pub(crate) plugin_config: Option, - pub(crate) max_hook_payload_bytes: usize, - pub(crate) max_passthrough_body_bytes: usize, -} - -#[derive(Debug, Clone, Args)] -pub(crate) struct HookForwardCommand { - #[arg(value_enum)] - pub(crate) agent: CodingAgent, - #[arg(long)] - pub(crate) gateway_url: Option, - #[arg(long)] - pub(crate) profile: Option, - #[arg(long)] - pub(crate) session_metadata: Option, - #[arg(long, value_enum)] - pub(crate) gateway_mode: Option, - #[arg(long)] - pub(crate) fail_closed: bool, -} - -/// Args for the easy-path agent shortcut (`nemo-relay claude`, `nemo-relay codex`, etc.). -/// Holds only pass-through agent args; the agent itself is selected by which subcommand variant -/// is invoked, and upstream settings come from the resolved config file. If no config file is -/// present, the dispatcher fires setup. -#[derive(Debug, Clone, Args)] -pub(crate) struct EasyPathCommand { - /// Pass-through args forwarded to the underlying agent process. Use `--` to separate them - /// from `nemo-relay`'s own flags. See the `Examples` section below for agent-specific shapes. - #[arg(last = true)] - pub(crate) command: Vec, -} - -#[derive(Debug, Clone, Args)] -pub(crate) struct RunCommand { - #[arg(long, value_enum)] - pub(crate) agent: Option, - #[arg(long)] - pub(crate) config: Option, - #[arg(long)] - pub(crate) openai_base_url: Option, - #[arg(long)] - pub(crate) anthropic_base_url: Option, - #[arg(long)] - pub(crate) session_metadata: Option, - /// Internal override for the plugin configuration file. - #[arg(long, env = "NEMO_RELAY_PLUGIN_CONFIG_PATH", hide = true)] - pub(crate) plugin_config_path: Option, - #[arg(long)] - pub(crate) dry_run: bool, - #[arg(long)] - pub(crate) print: bool, - #[arg(last = true)] - pub(crate) command: Vec, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] -#[value(rename_all = "kebab-case")] -pub(crate) enum CodingAgent { - /// Canonical CLI spelling is `claude` (matches Anthropic's own binary name and the TOML - /// `[agents.claude]` key). `claude-code` is kept as an input alias for backward compat - /// with hooks installed before this rename. - #[value(name = "claude", alias = "claude-code")] - ClaudeCode, - Codex, - Hermes, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, ValueEnum)] -#[value(rename_all = "kebab-case")] -pub(crate) enum PluginHost { - Codex, - #[value(name = "claude-code", alias = "claude")] - ClaudeCode, - All, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] -#[value(rename_all = "kebab-case")] -pub(crate) enum GatewayMode { - HookOnly, - Passthrough, - Required, -} - -#[derive(Debug, Clone, Default)] -pub(crate) struct SessionConfig { - pub(crate) metadata: Option, - pub(crate) plugin_config: Option, - pub(crate) profile: Option, - pub(crate) gateway_mode: Option, -} - -impl GatewayConfig { - // Resolves per-session settings from hook/gateway headers with process config as fallback. - // Header JSON fields are parsed opportunistically; invalid JSON is treated as absent here - // because install and hook-forward validate generated header values before sending them. - pub(crate) fn session_config_from_headers(&self, headers: &HeaderMap) -> SessionConfig { - let metadata = - header_json(headers, "x-nemo-relay-session-metadata").or_else(|| self.metadata.clone()); - let plugin_config = header_json(headers, "x-nemo-relay-plugin-config") - .or_else(|| self.plugin_config.clone()); - let profile = header_string(headers, "x-nemo-relay-config-profile"); - let gateway_mode = header_string(headers, "x-nemo-relay-gateway-mode"); - SessionConfig { - metadata, - plugin_config, - profile, - gateway_mode, - } - } -} - -#[derive(Debug, Clone, Default)] -pub(crate) struct ResolvedConfig { - pub(crate) gateway: GatewayConfig, - pub(crate) agents: AgentConfigs, - pub(crate) dynamic_plugins: Vec, - pub(crate) dynamic_plugin_policy: DynamicPluginHostPolicy, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) struct ResolvedDynamicPluginConfig { - pub(crate) plugin_id: String, - pub(crate) manifest_ref: String, - pub(crate) config: Map, - pub(crate) has_explicit_config: bool, - pub(crate) source: PathBuf, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Display, IntoStaticStr)] -#[serde(rename_all = "snake_case")] -#[strum(serialize_all = "snake_case")] -pub(crate) enum DynamicPluginHostConfigStatus { - Absent, - Present, -} - -impl ResolvedDynamicPluginConfig { - pub(crate) fn host_config_status(&self) -> DynamicPluginHostConfigStatus { - if self.has_explicit_config { - DynamicPluginHostConfigStatus::Present - } else { - DynamicPluginHostConfigStatus::Absent - } - } -} - -#[derive(Debug, Clone, Default)] -pub(crate) struct AgentConfigs { - pub(crate) claude: AgentCommandConfig, - pub(crate) codex: AgentCommandConfig, - pub(crate) hermes: AgentCommandConfig, -} - -#[derive(Debug, Clone, Default)] -pub(crate) struct AgentCommandConfig { - pub(crate) command: Option, - /// Recorded by `nemo-relay config` when it installs hermes shell hooks. Other agents leave - /// this empty; the launcher reads it only to print a "hooks live here" pointer for hermes. - pub(crate) hooks_path: Option, -} - -// TOML file shape grouped by user intent. Sections map 1:1 onto fields already present on -// `GatewayConfig` / `AgentConfigs`; plugin configuration lives in `plugins.toml`. -#[derive(Debug, Clone, Default, Deserialize)] -struct FileConfig { - gateway: Option, - upstream: Option, - agents: Option, -} - -#[derive(Debug, Clone, Default, Deserialize)] -struct FileGatewayConfig { - max_hook_payload_bytes: Option, - max_passthrough_body_bytes: Option, -} - -#[derive(Debug, Clone, Default, Deserialize)] -struct FileUpstreamConfig { - openai_base_url: Option, - anthropic_base_url: Option, -} - -#[derive(Debug, Clone, Default, Deserialize)] -struct FileAgentsConfig { - // Keys match the agent's CLI invocation name (`claude`, `codex`, `hermes`) — the - // word the user types at the shell — not the product name ("Claude Code") or the internal - // `CodingAgent` enum kebab spelling. Same convention as the bare-agent shortcut in Phase 2. - claude: Option, - codex: Option, - hermes: Option, -} - -#[derive(Debug, Clone, Default, Deserialize)] -struct FileAgentCommandConfig { - command: Option, - hooks_path: Option, -} - -impl Default for GatewayConfig { - // Supplies conservative local gateway defaults: bind only to loopback, route OpenAI and - // Anthropic requests to their public bases, and leave plugins disabled until config, - // environment, or headers explicitly opt in. - fn default() -> Self { - Self { - bind: "127.0.0.1:4040" - .parse() - .expect("valid default bind address"), - openai_base_url: "https://api.openai.com/v1".into(), - anthropic_base_url: "https://api.anthropic.com".into(), - metadata: None, - plugin_config: None, - max_hook_payload_bytes: DEFAULT_MAX_HOOK_PAYLOAD_BYTES, - max_passthrough_body_bytes: DEFAULT_MAX_PASSTHROUGH_BODY_BYTES, - } - } -} - -/// Resolves server-mode configuration from shared config files plus server CLI/environment overrides. -/// -/// File discovery and merge behavior live in `load_shared_config`; this function only applies the -/// server-facing command-line layer so launcher-only settings cannot leak into daemon mode. -pub(crate) fn resolve_server_config(args: &ServerArgs) -> Result { - let mut resolved = load_shared_config(args.config.as_ref(), args.plugin_config_path.as_ref())?; - apply_server_overrides(&mut resolved.gateway, args)?; - enforce_required_dynamic_plugin_startup(args.config.as_ref(), &resolved)?; - Ok(resolved) -} - -/// Resolves shared config for plugin-facing CLI commands without mutating gateway runtime fields. -pub(crate) fn resolve_plugins_config( - explicit: Option<&PathBuf>, -) -> Result { - load_shared_config(explicit, None) -} - -/// Resolves transparent `run` configuration and switches the gateway to an ephemeral bind address. -/// -/// Explicit run arguments override inherited top-level server flags, which override shared config. -/// Session metadata and plugin config are parsed as JSON here so malformed CLI values fail before -/// the child agent is spawned. -pub(crate) fn resolve_run_config( - command: &RunCommand, - inherited: Option<&ServerArgs>, -) -> Result { - let config = command - .config - .as_ref() - .or_else(|| inherited.and_then(|args| args.config.as_ref())); - let plugin_config_path = command - .plugin_config_path - .as_ref() - .or_else(|| inherited.and_then(|args| args.plugin_config_path.as_ref())); - let mut resolved = load_shared_config(config, plugin_config_path)?; - if let Some(args) = inherited { - apply_server_overrides(&mut resolved.gateway, args)?; - } - apply_run_overrides(&mut resolved.gateway, command)?; - resolved.gateway.bind = "127.0.0.1:0" - .parse() - .expect("valid transparent bind address"); - if !command.dry_run { - enforce_required_dynamic_plugin_startup(config, &resolved)?; - } - Ok(resolved) -} - -// Applies subcommand-specific `run` overrides after inherited top-level flags. JSON-bearing fields -// are parsed here so invalid metadata or plugin config fails before the gateway binds a port. -fn apply_run_overrides(config: &mut GatewayConfig, command: &RunCommand) -> Result<(), CliError> { - apply_run_url_overrides(config, command); - apply_run_json_overrides(config, command)?; - Ok(()) -} - -// Applies plain string/path run overrides. These fields do not need parsing, so they stay separate -// from JSON options whose errors should include field context. -fn apply_run_url_overrides(config: &mut GatewayConfig, command: &RunCommand) { - if let Some(value) = &command.openai_base_url { - config.openai_base_url = value.clone(); - } - if let Some(value) = &command.anthropic_base_url { - config.anthropic_base_url = value.clone(); - } -} - -// Parses JSON-bearing run overrides after simple values. Invalid metadata or plugin config fails -// before transparent run mode binds its ephemeral gateway listener. -fn apply_run_json_overrides( - config: &mut GatewayConfig, - command: &RunCommand, -) -> Result<(), CliError> { - if let Some(value) = &command.session_metadata { - config.metadata = Some(parse_json_option("session metadata", value)?); - } - Ok(()) -} - -// Applies direct server flags on top of already-merged configuration. Only present options mutate -// the config so lower-priority file values survive when a flag was omitted. -fn apply_server_overrides(config: &mut GatewayConfig, args: &ServerArgs) -> Result<(), CliError> { - if let Some(value) = args.bind { - config.bind = value; - } - if let Some(value) = &args.openai_base_url { - config.openai_base_url = value.clone(); - } - if let Some(value) = &args.anthropic_base_url { - config.anthropic_base_url = value.clone(); - } - if let Some(value) = args.max_hook_payload_bytes { - config.max_hook_payload_bytes = validate_body_limit("max hook payload bytes", value)?; - } - if let Some(value) = args.max_passthrough_body_bytes { - config.max_passthrough_body_bytes = - validate_body_limit("max passthrough body bytes", value)?; - } - Ok(()) -} - -pub(crate) const PLUGINS_TOML: &str = "plugins.toml"; - -// Loads config from the ordered shared locations, deep-merges TOML tables, maps the typed file -// shape onto runtime structs, applies a sibling/discovered plugins.toml when present, then lets -// environment variables override file values. Invalid TOML or typed shapes fail closed because -// they indicate an operator configuration error. -fn load_shared_config( - explicit: Option<&PathBuf>, - plugin_config_path: Option<&PathBuf>, -) -> Result { - let mut merged = toml::Value::Table(toml::map::Map::new()); - for path in config_paths(explicit) { - let Some(raw) = read_config_file(&path, explicit.is_some(), "configuration")? else { - continue; - }; - let parsed = raw - .parse::() - .map(toml::Value::Table) - .map_err(|error| { - CliError::Config(format!("invalid TOML in {}: {error}", path.display())) - })?; - let legacy_observability = legacy_observability_sections(&parsed); - if !legacy_observability.is_empty() { - return Err(CliError::Config(format!( - "legacy observability config in {} is no longer supported: {}; configure \ - observability in plugins.toml with `nemo-relay plugins edit`", - path.display(), - legacy_observability.join(", ") - ))); - } - if parsed.get("plugins").is_some() { - return Err(CliError::Config(format!( - "plugin configuration in {} is no longer supported; move it to plugins.toml", - path.display() - ))); - } - merge_toml(&mut merged, parsed); - } - let plugin_toml = load_plugin_toml_config(explicit, plugin_config_path)?; - let mut resolved = ResolvedConfig { - gateway: GatewayConfig::default(), - ..ResolvedConfig::default() - }; - apply_file_config(&mut resolved, merged)?; - apply_plugin_toml_config(&mut resolved, plugin_toml); - apply_env_config(&mut resolved.gateway)?; - Ok(resolved) -} - -fn read_config_file( - path: &Path, - required: bool, - description: &str, -) -> Result, CliError> { - match path.try_exists() { - Ok(false) if !required => Ok(None), - Ok(false) => Err(CliError::Config(format!( - "explicit {description} file {} does not exist", - path.display() - ))), - Err(error) => Err(CliError::Config(format!( - "failed to inspect {description} file {}: {error}", - path.display() - ))), - Ok(true) => std::fs::read_to_string(path).map(Some).map_err(|error| { - CliError::Config(format!( - "failed to read {description} file {}: {error}", - path.display() - )) - }), - } -} - -/// Returns true if any of the implicit config file locations exists on disk. Used by the -/// easy-path dispatcher to decide whether to launch setup (no config found) or proceed -/// with config-driven settings. Mirrors `config_paths(None)` but only checks existence. -pub(crate) fn any_config_file_exists() -> bool { - config_paths(None).iter().any(|path| path.exists()) -} - -// Returns the config search path. An explicit path disables implicit discovery; otherwise system -// config is lowest priority, the nearest project config is next, and user config is merged last. -fn config_paths(explicit: Option<&PathBuf>) -> Vec { - if let Some(path) = explicit { - return vec![path.clone()]; - } - let mut paths = vec![PathBuf::from("/etc/nemo-relay/config.toml")]; - if let Ok(cwd) = std::env::current_dir() - && let Some(project) = find_project_config(&cwd) - { - paths.push(project); - } - if let Some(user) = user_config_path() { - paths.push(user); - } - paths -} - -// Returns the plugin config search path. An explicit gateway config path scopes plugins.toml to the -// same directory so `--config path/to/config.toml` can be extended by `path/to/plugins.toml` without -// reading unrelated implicit project/user/global plugin files. -fn plugin_config_paths( - explicit: Option<&PathBuf>, - plugin_config_path: Option<&PathBuf>, -) -> Vec { - if let Some(path) = plugin_config_path { - return vec![path.clone()]; - } - if let Some(path) = explicit { - return path - .parent() - .map(|parent| vec![parent.join(PLUGINS_TOML)]) - .unwrap_or_default(); - } - implicit_plugin_config_paths(std::env::current_dir().ok().as_deref(), user_config_dir()) -} - -/// Returns the implicit `plugins.toml` discovery paths used by the gateway and doctor. -pub(crate) fn default_plugin_config_paths() -> Vec { - plugin_config_paths(None, None) -} - -fn implicit_plugin_config_paths( - cwd: Option<&std::path::Path>, - user_config_dir: Option, -) -> Vec { - // The search-path logic lives in core; the gateway shares it so discovery stays identical. - nemo_relay::plugin::default_plugin_config_paths(cwd, user_config_dir) -} - -// Walks upward from the current directory and returns the nearest project-local gateway config. -// The first hit wins so nested projects can override parent workspace defaults. -fn find_project_config(start: &std::path::Path) -> Option { - for ancestor in start.ancestors() { - let path = ancestor.join(".nemo-relay/config.toml"); - if path.exists() { - return Some(path); - } - } - None -} - -// The project-walk lives in core; the gateway shares it so discovery stays identical. -fn find_project_plugin_config(start: &std::path::Path) -> Option { - nemo_relay::plugin::nearest_project_plugin_config(start) -} - -pub(crate) fn user_plugin_config_path() -> Option { - user_config_dir().map(|dir| dir.join(PLUGINS_TOML)) -} - -pub(crate) fn project_plugin_config_path(start: &std::path::Path) -> PathBuf { - find_project_plugin_config(start) - .or_else(|| { - find_project_config(start) - .and_then(|path| path.parent().map(|parent| parent.join(PLUGINS_TOML))) - }) - .unwrap_or_else(|| start.join(".nemo-relay").join(PLUGINS_TOML)) -} - -pub(crate) fn global_plugin_config_path() -> PathBuf { - PathBuf::from("/etc/nemo-relay").join(PLUGINS_TOML) -} - -// Resolves the user config using XDG first and HOME/USERPROFILE second. Returning `None` keeps -// config loading portable in minimal environments where no home directory is visible. -fn user_config_path() -> Option { - user_config_dir().map(|dir| dir.join("config.toml")) -} - -/// Resolves the nemo-relay user config DIRECTORY (without trailing filename). Delegates to core's -/// resolver so the gateway, the editor, and the plugin runtime agree on the location. -pub(crate) fn user_config_dir() -> Option { - nemo_relay::plugin::user_config_dir() -} - -// Applies the typed TOML config model to the resolved runtime config. Missing sections and fields -// are ignored, preserving defaults and prior merge layers. -fn apply_file_config(resolved: &mut ResolvedConfig, value: toml::Value) -> Result<(), CliError> { - let config: FileConfig = value.try_into().map_err(|error| { - CliError::Config(format!("invalid gateway configuration shape: {error}")) - })?; - apply_file_gateway_config(&mut resolved.gateway, config.gateway)?; - apply_file_upstream_config(&mut resolved.gateway, config.upstream); - apply_file_agents_config(&mut resolved.agents, config.agents); - Ok(()) -} - -fn apply_file_gateway_config( - gateway: &mut GatewayConfig, - config: Option, -) -> Result<(), CliError> { - let Some(config) = config else { - return Ok(()); - }; - if let Some(value) = config.max_hook_payload_bytes { - gateway.max_hook_payload_bytes = - validate_body_limit("gateway.max_hook_payload_bytes", value)?; - } - if let Some(value) = config.max_passthrough_body_bytes { - gateway.max_passthrough_body_bytes = - validate_body_limit("gateway.max_passthrough_body_bytes", value)?; - } - Ok(()) -} - -// Applies upstream LLM provider URLs. These are the bases for OpenAI- and Anthropic-shaped -// gateway routes; transparent `run` mode can still override them per invocation. -fn apply_file_upstream_config(gateway: &mut GatewayConfig, upstream: Option) { - let Some(upstream) = upstream else { - return; - }; - if let Some(value) = upstream.openai_base_url { - gateway.openai_base_url = value; - } - if let Some(value) = upstream.anthropic_base_url { - gateway.anthropic_base_url = value; - } -} - -#[derive(Debug, Clone)] -struct PluginTomlConfig { - value: Option, - dynamic_plugins: Vec, - dynamic_plugin_policy: DynamicPluginHostPolicy, - contributing_sources: Vec, -} - -#[derive(Debug, Clone, Default, Deserialize)] -struct PluginTomlPluginsSection { - #[serde(default)] - dynamic: Vec, - #[serde(default)] - policy: Option, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(deny_unknown_fields)] -struct FileDynamicPluginConfig { - manifest: String, - #[serde(default)] - config: Option>, -} - -fn load_plugin_toml_config( - explicit: Option<&PathBuf>, - plugin_config_path: Option<&PathBuf>, -) -> Result, CliError> { - load_plugin_toml_config_from_paths(plugin_config_paths(explicit, plugin_config_path)) -} - -/// Returns the physical `plugins.toml` files that contribute effective runtime or dynamic -/// plugin configuration under the default discovery rules. -pub(crate) fn effective_plugin_toml_sources() -> Result, CliError> { - let Some(config) = load_plugin_toml_config(None, None)? else { - return Ok(Vec::new()); - }; - let mut sources = config.contributing_sources; - sources.sort(); - sources.dedup(); - Ok(sources) -} - -fn load_plugin_toml_config_from_paths(paths: I) -> Result, CliError> -where - I: IntoIterator, -{ - let paths = paths.into_iter().collect::>(); - let mut dynamic_plugins = Vec::new(); - let mut dynamic_plugin_policy = DynamicPluginHostPolicy::default(); - let mut seen_plugin_ids = HashSet::new(); - let mut contributing_sources = Vec::new(); - let mut runtime_documents = Vec::new(); - - for path in &paths { - let Some(raw) = read_config_file(path, false, "plugin configuration")? else { - continue; - }; - let mut parsed = raw - .parse::() - .map(toml::Value::Table) - .map_err(|error| { - CliError::Config(format!( - "invalid plugin TOML in {}: {error}", - path.display() - )) - })?; - let resolved_plugins = - resolve_dynamic_plugin_refs(path, &mut parsed, &mut seen_plugin_ids)?; - if !resolved_plugins.dynamic_plugins.is_empty() - || resolved_plugins.dynamic_plugin_policy != DynamicPluginHostPolicy::default() - { - contributing_sources.push(path.clone()); - } - dynamic_plugins.extend(resolved_plugins.dynamic_plugins); - dynamic_plugin_policy.merge_from(resolved_plugins.dynamic_plugin_policy); - runtime_documents.push(( - path.clone(), - serde_json::to_value(remove_dynamic_plugin_sections(parsed)) - .expect("toml value serializes to JSON"), - )); - } - - // Delegate merged runtime plugin config to the shared core primitive after dynamic refs have - // been validated independently. File precedence stays unchanged for the generic runtime path. - let resolved = merge_plugin_config_documents(runtime_documents).map_err(|err| match err { - PluginError::InvalidConfig(message) => CliError::Config(message), - other => CliError::Config(other.to_string()), - })?; - match resolved { - Some((value, sources)) => { - contributing_sources.extend(sources.iter().cloned()); - contributing_sources.sort(); - contributing_sources.dedup(); - Ok(Some(PluginTomlConfig { - value: plugin_toml_runtime_value(value), - dynamic_plugins, - dynamic_plugin_policy, - contributing_sources, - })) - } - None => Ok((!dynamic_plugins.is_empty() - || dynamic_plugin_policy != DynamicPluginHostPolicy::default()) - .then_some(PluginTomlConfig { - value: None, - dynamic_plugins, - dynamic_plugin_policy, - contributing_sources, - })), - } -} - -fn apply_plugin_toml_config(resolved: &mut ResolvedConfig, plugin_toml: Option) { - let Some(plugin_toml) = plugin_toml else { - return; - }; - if let Some(value) = plugin_toml.value { - resolved.gateway.plugin_config = Some(value); - } - resolved.dynamic_plugins = plugin_toml.dynamic_plugins; - resolved.dynamic_plugin_policy = plugin_toml.dynamic_plugin_policy; -} - -struct ResolvedDynamicPluginRefs { - dynamic_plugins: Vec, - dynamic_plugin_policy: DynamicPluginHostPolicy, -} - -fn resolve_dynamic_plugin_refs( - source: &Path, - value: &mut toml::Value, - seen_plugin_ids: &mut HashSet, -) -> Result { - let Some(root) = value.as_table_mut() else { - return Ok(ResolvedDynamicPluginRefs { - dynamic_plugins: Vec::new(), - dynamic_plugin_policy: DynamicPluginHostPolicy::default(), - }); - }; - - let plugins_value = root.get("plugins").cloned(); - let Some(plugins_value) = plugins_value else { - return Ok(ResolvedDynamicPluginRefs { - dynamic_plugins: Vec::new(), - dynamic_plugin_policy: DynamicPluginHostPolicy::default(), - }); - }; - - let plugins: PluginTomlPluginsSection = plugins_value.try_into().map_err(|error| { - CliError::Config(format!( - "invalid dynamic plugin config in {}: {error}", - source.display() - )) - })?; - - if let Some(toml::Value::Table(plugins_table)) = root.get_mut("plugins") { - plugins_table.remove("dynamic"); - plugins_table.remove("policy"); - if plugins_table.is_empty() { - root.remove("plugins"); - } - } - - let mut resolved = Vec::with_capacity(plugins.dynamic.len()); - for dynamic in plugins.dynamic { - let manifest_path = resolve_dynamic_manifest_path(source, &dynamic.manifest); - let (manifest, manifest_ref) = DynamicPluginManifest::load_from_path(&manifest_path) - .map_err(|error| { - CliError::Config(format!( - "invalid dynamic plugin manifest referenced by {}: {error}", - source.display() - )) - })?; - let plugin_id = manifest.plugin.id.trim().to_owned(); - if !seen_plugin_ids.insert(plugin_id.clone()) { - return Err(CliError::Config(format!( - "duplicate dynamic plugin id '{}' in {} across plugins.toml sources", - plugin_id, - source.display() - ))); - } - resolved.push(ResolvedDynamicPluginConfig { - plugin_id, - manifest_ref, - has_explicit_config: dynamic.config.is_some(), - config: dynamic.config.unwrap_or_default(), - source: source.to_path_buf(), - }); - } - Ok(ResolvedDynamicPluginRefs { - dynamic_plugins: resolved, - dynamic_plugin_policy: plugins.policy.map(Into::into).unwrap_or_default(), - }) -} - -fn resolve_dynamic_manifest_path(source: &Path, manifest: &str) -> PathBuf { - let manifest = PathBuf::from(manifest); - if manifest.is_absolute() { - manifest - } else { - source - .parent() - .map(|parent| parent.join(&manifest)) - .unwrap_or(manifest) - } -} - -fn plugin_toml_runtime_value(value: Value) -> Option { - match value { - Value::Object(ref object) if object.is_empty() => None, - other => Some(other), - } -} - -fn remove_dynamic_plugin_sections(mut value: toml::Value) -> toml::Value { - if let Some(root) = value.as_table_mut() - && let Some(toml::Value::Table(plugins)) = root.get_mut("plugins") - { - plugins.remove("dynamic"); - plugins.remove("policy"); - if plugins.is_empty() { - root.remove("plugins"); - } - } - value -} - -// Applies configured agent commands from the merged file configuration. -fn apply_file_agents_config(agents: &mut AgentConfigs, file_agents: Option) { - let Some(file_agents) = file_agents else { - return; - }; - if let Some(value) = file_agents.claude { - agents.claude.command = value.command; - } - if let Some(value) = file_agents.codex { - agents.codex.command = value.command; - } - if let Some(value) = file_agents.hermes { - agents.hermes.command = value.command; - agents.hermes.hooks_path = value.hooks_path; - } -} - -// Applies environment variables after file configuration. Invalid bind values are ignored here to -// preserve existing startup behavior, while string values replace earlier layers when present. -fn apply_env_config(config: &mut GatewayConfig) -> Result<(), CliError> { - if let Ok(value) = std::env::var("NEMO_RELAY_GATEWAY_BIND") - && let Ok(value) = value.parse() - { - config.bind = value; - } - if let Ok(value) = std::env::var("NEMO_RELAY_OPENAI_BASE_URL") { - config.openai_base_url = value; - } - if let Ok(value) = std::env::var("NEMO_RELAY_ANTHROPIC_BASE_URL") { - config.anthropic_base_url = value; - } - if let Ok(value) = std::env::var("NEMO_RELAY_MAX_HOOK_PAYLOAD_BYTES") { - config.max_hook_payload_bytes = - parse_env_body_limit("NEMO_RELAY_MAX_HOOK_PAYLOAD_BYTES", &value)?; - } - if let Ok(value) = std::env::var("NEMO_RELAY_MAX_PASSTHROUGH_BODY_BYTES") { - config.max_passthrough_body_bytes = - parse_env_body_limit("NEMO_RELAY_MAX_PASSTHROUGH_BODY_BYTES", &value)?; - } - Ok(()) -} - -fn parse_env_body_limit(name: &str, raw: &str) -> Result { - let value = raw.parse::().map_err(|error| { - CliError::Config(format!("{name} must be a positive byte count: {error}")) - })?; - validate_body_limit(name, value) -} - -fn validate_body_limit(name: &str, value: usize) -> Result { - if value == 0 { - return Err(CliError::Config(format!("{name} must be greater than 0"))); - } - Ok(value) -} - -// Recursively merges TOML tables and replaces scalar/array values from the higher-priority side. -// This lets user/project configs override individual nested keys without restating whole sections. -fn merge_toml(left: &mut toml::Value, right: toml::Value) { - match (left, right) { - (toml::Value::Table(left), toml::Value::Table(right)) => { - for (key, value) in right { - match left.get_mut(&key) { - Some(existing) => merge_toml(existing, value), - None => { - left.insert(key, value); - } - } - } - } - (left, right) => *left = right, - } -} - -fn legacy_observability_sections(value: &toml::Value) -> Vec<&'static str> { - let mut sections = Vec::new(); - if value.get("exporters").is_some() { - sections.push("[exporters]"); - } - if value.get("observability").is_some() { - sections.push("[observability]"); - } - if value - .get("export") - .and_then(|export| export.get("openinference")) - .is_some() - { - sections.push("[export.openinference]"); - } - sections -} - -// Parses JSON-valued CLI options into runtime metadata/config values and labels errors with the -// user-facing option name so callers can report which structured argument was malformed. -fn parse_json_option(name: &str, value: &str) -> Result { - serde_json::from_str::(value) - .map_err(|error| CliError::Config(format!("invalid {name}: {error}"))) -} - -/// Reads a non-empty UTF-8 header value as an owned string. -/// -/// Invalid header bytes and empty strings are treated as absent so callers can preserve their -/// explicit fallback order without surfacing HTTP parsing details as gateway errors. -pub(crate) fn header_string(headers: &HeaderMap, name: &str) -> Option { - headers - .get(name) - .and_then(|value| value.to_str().ok()) - .filter(|value| !value.is_empty()) - .map(ToOwned::to_owned) -} - -fn header_json(headers: &HeaderMap, name: &str) -> Option { - header_string(headers, name).and_then(|raw| serde_json::from_str(&raw).ok()) -} - -impl CodingAgent { - // Returns the gateway hook endpoint for the agent. These paths are stable integration surface - // because installed hook commands persist them in user or project configuration. - pub(crate) const fn hook_path(self) -> &'static str { - match self { - Self::ClaudeCode => "/hooks/claude-code", - Self::Codex => "/hooks/codex", - Self::Hermes => "/hooks/hermes", - } - } - - // Returns the canonical CLI spelling used in generated commands and diagnostics. Matches the - // clap `#[value(name = ...)]` overrides on the enum so install/run output can be copied back - // into commands. `claude` matches Anthropic's binary name and the TOML `[agents.claude]` key. - pub(crate) const fn as_arg(self) -> &'static str { - match self { - Self::ClaudeCode => "claude", - Self::Codex => "codex", - Self::Hermes => "hermes", - } - } - - // Infers an agent from the executable basename, accepting both canonical project names and - // common command aliases. Path components are stripped so configured absolute commands work. - pub(crate) fn infer(command: &str) -> Option { - let name = std::path::Path::new(command) - .file_name() - .and_then(|value| value.to_str()) - .unwrap_or(command); - match name { - "claude" | "claude-code" => Some(Self::ClaudeCode), - "codex" => Some(Self::Codex), - "hermes" | "hermes-agent" => Some(Self::Hermes), - _ => None, - } - } -} - -impl GatewayMode { - // Returns the installed hook-forward spelling for gateway mode headers. Keeping this separate - // from debug output prevents enum formatting changes from affecting persisted hook commands. - pub(crate) const fn as_arg(self) -> &'static str { - match self { - Self::HookOnly => "hook-only", - Self::Passthrough => "passthrough", - Self::Required => "required", - } - } -} - -#[cfg(test)] -#[path = "../tests/coverage/config_tests.rs"] -mod tests; diff --git a/crates/cli/src/configuration/mod.rs b/crates/cli/src/configuration/mod.rs new file mode 100644 index 000000000..0aebdaa2c --- /dev/null +++ b/crates/cli/src/configuration/mod.rs @@ -0,0 +1,1513 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +mod types; + +pub(crate) use types::*; + +use std::collections::HashSet; +use std::env; +use std::fs::{self, OpenOptions}; +use std::io::{Read, Seek, SeekFrom, Write}; +use std::path::{Path, PathBuf}; +use std::thread; +use std::time::{Duration, Instant}; + +use axum::http::HeaderMap; +use nemo_relay::plugin::dynamic::{ + DYNAMIC_PLUGIN_MANIFEST_FILENAME, DynamicPluginManifest, DynamicPluginManifestLoad, +}; +use nemo_relay::plugin::{PluginError, merge_plugin_config_documents}; +use ring::rand::{SecureRandom, SystemRandom}; +use ring::{digest, hmac}; +use serde::Deserialize; +use serde_json::{Map, Value}; + +use crate::error::CliError; +use crate::filesystem::{LockAttempt, try_lock_exclusive, try_lock_shared}; +#[cfg(test)] +use crate::plugins::lifecycle::active_dynamic_plugin_components; +use crate::plugins::lifecycle::{ + ActiveDynamicPluginComponent, active_dynamic_plugin_components_for_identity, + dynamic_plugin_runtime_closure_digest, enforce_required_dynamic_plugin_startup, +}; +use crate::plugins::policy::DynamicPluginHostPolicy; +use crate::process::RunOverrides; +use crate::server::GatewayOverrides; + +pub(crate) const BOOTSTRAP_FINGERPRINT_ENV: &str = "NEMO_RELAY_BOOTSTRAP_FINGERPRINT"; +pub(crate) const PLUGIN_IDLE_TIMEOUT_ENV: &str = "NEMO_RELAY_PLUGIN_IDLE_TIMEOUT_SECS"; +pub(crate) const RELAY_PLUGIN_ID: &str = "nemo-relay-plugin@nemo-relay-local"; +pub(crate) const RELAY_SOURCE_PLUGIN_ID: &str = "nemo-relay-plugin@nemo-relay"; +pub(crate) const DEFAULT_MAX_HOOK_PAYLOAD_BYTES: usize = 20 * 1024 * 1024; +pub(crate) const DEFAULT_MAX_PASSTHROUGH_BODY_BYTES: usize = 100 * 1024 * 1024; +pub(crate) const GATEWAY_URL_ENV: &str = "NEMO_RELAY_GATEWAY_URL"; +pub(crate) const TRANSPARENT_RUN_ENV: &str = "NEMO_RELAY_TRANSPARENT_RUN"; + +// TOML file shape grouped by user intent. Sections map 1:1 onto fields already present on +// `GatewayConfig` / `AgentConfigs`; plugin configuration lives in `plugins.toml`. +#[derive(Debug, Clone, Default, Deserialize)] +struct FileConfig { + gateway: Option, + upstream: Option, + agents: Option, +} + +#[derive(Debug, Clone, Default, Deserialize)] +struct FileGatewayConfig { + max_hook_payload_bytes: Option, + max_passthrough_body_bytes: Option, +} + +#[derive(Debug, Clone, Default, Deserialize)] +struct FileUpstreamConfig { + openai_base_url: Option, + anthropic_base_url: Option, +} + +#[derive(Debug, Clone, Default, Deserialize)] +struct FileAgentsConfig { + // Keys match the agent's CLI invocation name (`claude`, `codex`, `hermes`) — the + // word the user types at the shell — not the product name ("Claude Code") or the internal + // `CodingAgent` enum kebab spelling. Same convention as the bare-agent shortcut in Phase 2. + claude: Option, + codex: Option, + hermes: Option, +} + +#[derive(Debug, Clone, Default, Deserialize)] +struct FileAgentCommandConfig { + command: Option, + hooks_path: Option, +} + +/// Resolves server-mode configuration from shared config files plus server CLI/environment overrides. +/// +/// File discovery and merge behavior live in `load_shared_config`; this function only applies the +/// server-facing command-line layer so launcher-only settings cannot leak into daemon mode. +pub(crate) fn resolve_server_config(args: &GatewayOverrides) -> Result { + let mut resolved = load_shared_config(args.config.as_ref(), args.plugin_config_path.as_ref())?; + apply_server_overrides(&mut resolved.gateway, args)?; + enforce_required_dynamic_plugin_startup(args.config.as_ref(), &resolved)?; + Ok(resolved) +} + +/// Resolves the shared plugin MCP gateway from system and user layers only. +pub(crate) fn resolve_persistent_server_config( + args: &GatewayOverrides, +) -> Result { + if args.config.is_some() || args.plugin_config_path.is_some() || args.ready_file.is_some() { + return Err(CliError::Config( + "nemo-relay mcp uses system and user configuration only; use `nemo-relay run` for explicit or project configuration" + .into(), + )); + } + let mut resolved = load_shared_config_scoped(None, None, true)?; + apply_server_overrides(&mut resolved.gateway, args)?; + let active_dynamic_plugins = active_dynamic_plugin_components_for_identity(None, &resolved)?; + resolved.bootstrap_fingerprint = Some(persistent_bootstrap_fingerprint( + &resolved, + &active_dynamic_plugins, + )?); + Ok(resolved) +} + +/// Parent-computed identity and inputs needed to reverify a managed persistent gateway child. +#[derive(Debug, Clone)] +pub(crate) struct ManagedBootstrapIdentity { + expected: String, + persistent_args: GatewayOverrides, + resolved: ResolvedConfig, + active_dynamic_plugins: Vec, +} + +impl ManagedBootstrapIdentity { + pub(crate) fn fingerprint(&self) -> &str { + &self.expected + } + + pub(crate) fn verify_current(&self) -> Result<(), CliError> { + let snapshot_actual = + persistent_bootstrap_fingerprint(&self.resolved, &self.active_dynamic_plugins)?; + verify_managed_bootstrap_fingerprint(&self.expected, &snapshot_actual)?; + let resolved = resolve_persistent_server_config(&self.persistent_args)?; + let actual = resolved + .bootstrap_fingerprint + .expect("persistent gateway resolution sets a bootstrap fingerprint"); + verify_managed_bootstrap_fingerprint(&self.expected, &actual) + } +} + +/// Verifies and retains the parent-computed identity for a managed persistent gateway child. +/// +/// Ordinary daemon launches remain stateless: the internal ready-file contract identifies a child +/// spawned by the plugin bootstrap path. The child recomputes identity from the configuration and +/// active lifecycle records it is about to activate before publishing ownership or readiness. +pub(crate) fn managed_bootstrap_identity( + args: &GatewayOverrides, + resolved: &ResolvedConfig, + active_dynamic_plugins: &[ActiveDynamicPluginComponent], +) -> Result, CliError> { + if args.ready_file.is_none() { + return Ok(None); + } + let Some(expected) = env::var(BOOTSTRAP_FINGERPRINT_ENV) + .ok() + .filter(|fingerprint| !fingerprint.is_empty()) + else { + return Err(CliError::Config(format!( + "{BOOTSTRAP_FINGERPRINT_ENV} must be set and non-empty when a managed readiness file is requested" + ))); + }; + let actual = persistent_bootstrap_fingerprint(resolved, active_dynamic_plugins)?; + verify_managed_bootstrap_fingerprint(&expected, &actual)?; + let mut persistent_args = args.clone(); + persistent_args.ready_file = None; + Ok(Some(ManagedBootstrapIdentity { + expected, + persistent_args, + resolved: resolved.clone(), + active_dynamic_plugins: active_dynamic_plugins.to_vec(), + })) +} + +fn verify_managed_bootstrap_fingerprint(expected: &str, actual: &str) -> Result<(), CliError> { + if actual == expected { + return Ok(()); + } + Err(CliError::Config( + "persistent gateway identity changed during managed bootstrap; retry so the parent can resolve the current configuration" + .into(), + )) +} + +fn persistent_bootstrap_fingerprint( + resolved: &ResolvedConfig, + active_dynamic_plugins: &[ActiveDynamicPluginComponent], +) -> Result { + let dynamic_plugins = active_dynamic_plugins + .iter() + .map(dynamic_plugin_bootstrap_identity) + .collect::, _>>()?; + let gateway = &resolved.gateway; + let idle_timeout_secs = crate::bootstrap::plugin_idle_timeout() + .map_err(CliError::Config)? + .as_secs(); + let document = serde_json::json!({ + "bootstrap_protocol": crate::bootstrap::BOOTSTRAP_PROTOCOL_VERSION, + "relay_version": env!("CARGO_PKG_VERSION"), + "openai_base_url": gateway.openai_base_url, + "anthropic_base_url": gateway.anthropic_base_url, + "metadata": gateway.metadata, + "plugin_config": gateway.plugin_config, + "max_hook_payload_bytes": gateway.max_hook_payload_bytes, + "max_passthrough_body_bytes": gateway.max_passthrough_body_bytes, + "plugin_idle_timeout_secs": idle_timeout_secs, + "dynamic_plugins": dynamic_plugins, + "dynamic_plugin_policy": format!("{:?}", resolved.dynamic_plugin_policy), + }); + let key = load_or_create_bootstrap_hmac_key()?; + let key = hmac::Key::new(hmac::HMAC_SHA256, &key); + let mut digest = hmac::Context::with_key(&key); + digest.update( + &serde_json::to_vec(&document).expect("persistent gateway fingerprint serializes to JSON"), + ); + let environment = env::vars_os().filter_map(|(name, _)| name.into_string().ok()); + for name in crate::mcp_environment::forwarded_names(environment, gateway.plugin_config.as_ref()) + { + if name == PLUGIN_IDLE_TIMEOUT_ENV { + continue; + } + digest.update(&[0]); + digest.update(name.as_bytes()); + digest.update(&[0]); + if let Some(value) = env::var_os(&name) { + digest.update(value.to_string_lossy().as_bytes()); + } + } + let tag = digest.sign(); + Ok(format!( + "hmac-sha256:{}", + tag.as_ref() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::() + )) +} + +fn dynamic_plugin_bootstrap_identity( + plugin: &ActiveDynamicPluginComponent, +) -> Result { + let manifest_identity = match (&plugin.activation_snapshot, plugin.manifest_ref.as_deref()) { + (Some(snapshot), _) => Some(dynamic_plugin_snapshot_identity(snapshot)?), + (None, Some(manifest_ref)) => Some(dynamic_plugin_manifest_identity( + manifest_ref, + plugin.environment_ref.as_deref(), + )?), + (None, None) => None, + }; + Ok(serde_json::json!({ + "plugin_id": plugin.plugin_id, + "kind": format!("{:?}", plugin.kind), + "lifecycle_generation": plugin.lifecycle_generation, + "manifest": manifest_identity, + "environment_ref": plugin.environment_ref, + "config": plugin.config, + })) +} + +fn dynamic_plugin_snapshot_identity( + snapshot: &crate::plugins::lifecycle::DynamicPluginActivationSnapshot, +) -> Result { + let (manifest, _) = load_bounded_dynamic_plugin_manifest(snapshot.identity_manifest())?; + let manifest_path = PathBuf::from(snapshot.original_manifest_ref()); + let manifest_digest = bootstrap_file_digest( + snapshot.identity_manifest(), + "dynamic plugin manifest snapshot", + )?; + let artifact_ref = manifest + .source + .as_ref() + .and_then(|source| source.artifact.as_deref()) + .or(match &manifest.load { + DynamicPluginManifestLoad::RustDynamic(load) => load.library.as_deref(), + DynamicPluginManifestLoad::Worker(_) => None, + }); + let artifact = artifact_ref + .map(|artifact_ref| { + let logical_path = resolve_dynamic_plugin_relative_path(&manifest_path, artifact_ref); + let snapshot_path = snapshot.identity_file(&logical_path).ok_or_else(|| { + CliError::Config(format!( + "dynamic plugin activation snapshot is missing artifact {}", + logical_path.display() + )) + })?; + bootstrap_file_digest(snapshot_path, "dynamic plugin artifact snapshot") + .map(|digest| serde_json::json!({ "path": logical_path, "sha256": digest })) + }) + .transpose()?; + let signature = manifest + .integrity + .as_ref() + .and_then(|integrity| integrity.signature.as_deref()) + .map(|signature_ref| { + let logical_path = resolve_dynamic_plugin_relative_path(&manifest_path, signature_ref); + let snapshot_path = snapshot.identity_file(&logical_path).ok_or_else(|| { + CliError::Config(format!( + "dynamic plugin activation snapshot is missing signature {}", + logical_path.display() + )) + })?; + bootstrap_file_digest(snapshot_path, "dynamic plugin signature snapshot") + .map(|digest| serde_json::json!({ "path": logical_path, "sha256": digest })) + }) + .transpose()?; + Ok(serde_json::json!({ + "path": snapshot.original_manifest_ref(), + "sha256": manifest_digest, + "artifact": artifact, + "signature": signature, + "runtime_closure_sha256": snapshot.closure_digest(), + })) +} + +fn dynamic_plugin_manifest_identity( + manifest_ref: &str, + environment_ref: Option<&str>, +) -> Result { + let (manifest, normalized_ref) = load_bounded_dynamic_plugin_manifest(manifest_ref)?; + let manifest_path = PathBuf::from(&normalized_ref); + let manifest_digest = bootstrap_file_digest(&manifest_path, "dynamic plugin manifest")?; + let artifact_ref = manifest + .source + .as_ref() + .and_then(|source| source.artifact.as_deref()) + .or(match &manifest.load { + DynamicPluginManifestLoad::RustDynamic(load) => load.library.as_deref(), + DynamicPluginManifestLoad::Worker(_) => None, + }); + let artifact = artifact_ref + .map(|artifact_ref| { + let path = resolve_dynamic_plugin_relative_path(&manifest_path, artifact_ref); + bootstrap_file_digest(&path, "dynamic plugin artifact") + .map(|digest| serde_json::json!({ "path": path, "sha256": digest })) + }) + .transpose()?; + let signature = manifest + .integrity + .as_ref() + .and_then(|integrity| integrity.signature.as_deref()) + .map(|signature_ref| { + let path = resolve_dynamic_plugin_relative_path(&manifest_path, signature_ref); + bootstrap_file_digest(&path, "dynamic plugin signature") + .map(|digest| serde_json::json!({ "path": path, "sha256": digest })) + }) + .transpose()?; + let closure_digest = dynamic_plugin_runtime_closure_digest(&normalized_ref, environment_ref)?; + Ok(serde_json::json!({ + "path": normalized_ref, + "sha256": manifest_digest, + "artifact": artifact, + "signature": signature, + "runtime_closure_sha256": closure_digest, + })) +} + +fn resolve_dynamic_plugin_relative_path(manifest_path: &Path, reference: &str) -> PathBuf { + let path = PathBuf::from(reference); + if path.is_absolute() { + path + } else { + manifest_path + .parent() + .map(|parent| parent.join(&path)) + .unwrap_or(path) + } +} + +fn bootstrap_file_digest(path: &Path, description: &str) -> Result { + let mut context = digest::Context::new(&digest::SHA256); + crate::filesystem::bounded::stream_bounded_regular_file(path, description, |bytes| { + context.update(bytes) + }) + .map_err(CliError::Config)?; + Ok(context + .finish() + .as_ref() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect()) +} + +pub(crate) fn load_bounded_dynamic_plugin_manifest( + path: impl AsRef, +) -> Result<(DynamicPluginManifest, String), CliError> { + let (manifest, normalized, _) = load_bounded_dynamic_plugin_manifest_bytes(path)?; + Ok((manifest, normalized)) +} + +pub(crate) fn load_bounded_dynamic_plugin_manifest_bytes( + path: impl AsRef, +) -> Result<(DynamicPluginManifest, String, Vec), CliError> { + let path = path.as_ref(); + let manifest_path = if path.is_dir() { + path.join(DYNAMIC_PLUGIN_MANIFEST_FILENAME) + } else { + path.to_path_buf() + }; + let normalized = fs::canonicalize(&manifest_path).map_err(|error| { + CliError::Config(format!( + "failed to normalize dynamic plugin manifest {}: {error}", + manifest_path.display() + )) + })?; + let bytes = crate::filesystem::bounded::read_bounded_regular_file( + &normalized, + "dynamic plugin manifest", + ) + .map_err(CliError::Config)?; + let contents = std::str::from_utf8(&bytes).map_err(|error| { + CliError::Config(format!( + "dynamic plugin manifest {} is not UTF-8: {error}", + normalized.display() + )) + })?; + let manifest = DynamicPluginManifest::parse_toml(contents) + .map_err(|error| CliError::Config(error.to_string()))?; + Ok((manifest, normalized.to_string_lossy().into_owned(), bytes)) +} + +const BOOTSTRAP_HMAC_KEY_BYTES: usize = 32; +const BOOTSTRAP_HMAC_LOCK_TIMEOUT: Duration = Duration::from_secs(5); +const BOOTSTRAP_CHALLENGE_DOMAIN: &[u8] = b"nemo-relay/bootstrap-health/v1\0"; +const BOOTSTRAP_CLIENT_TOKEN_DOMAIN: &[u8] = b"nemo-relay/bootstrap-client/v1\0"; +const TRANSPARENT_GATEWAY_DOMAIN: &[u8] = b"nemo-relay/transparent-gateway/v1\0"; +const PYTHON_ENVIRONMENT_ATTESTATION_DOMAIN: &[u8] = + b"nemo-relay/python-environment-attestation/v1\0"; + +/// Private proof installed into supported coding-agent provider configuration. +pub(crate) const BOOTSTRAP_CLIENT_TOKEN_HEADER: &str = "x-nemo-relay-client-token"; + +/// Stable health-proof context shared by a transparent wrapper and plugin-owned MCP client. +pub(crate) fn transparent_gateway_fingerprint(gateway_url: &str) -> String { + let mut context = digest::Context::new(&digest::SHA256); + context.update(TRANSPARENT_GATEWAY_DOMAIN); + context.update(gateway_url.as_bytes()); + let encoded = context + .finish() + .as_ref() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::(); + format!("transparent-sha256:{encoded}") +} + +/// Per-user secret used to authenticate a managed bootstrap listener without exposing key bytes. +#[derive(Clone)] +pub(crate) struct BootstrapChallengeKey(hmac::Key); + +impl BootstrapChallengeKey { + pub(crate) fn load() -> Result { + Ok(Self(hmac::Key::new( + hmac::HMAC_SHA256, + &load_or_create_bootstrap_hmac_key()?, + ))) + } + + /// Loads an existing key without creating bootstrap state. Read-only diagnostics use this so + /// checking an uninstalled integration cannot mutate the user's configuration directory. + pub(crate) fn load_existing() -> Result, CliError> { + load_existing_bootstrap_hmac_key() + .map(|key| key.map(|key| Self(hmac::Key::new(hmac::HMAC_SHA256, &key)))) + } + + pub(crate) fn proof(&self, fingerprint: &str, nonce: &str) -> String { + let mut context = hmac::Context::with_key(&self.0); + context.update(BOOTSTRAP_CHALLENGE_DOMAIN); + context.update(fingerprint.as_bytes()); + context.update(&[0]); + context.update(nonce.as_bytes()); + encode_hmac_tag(context.sign()) + } + + pub(crate) fn verify(&self, fingerprint: &str, nonce: &str, proof: &str) -> bool { + let Some(encoded) = proof.strip_prefix("hmac-sha256:") else { + return false; + }; + let Some(tag) = decode_fixed_hex::<32>(encoded) else { + return false; + }; + let mut message = Vec::with_capacity( + BOOTSTRAP_CHALLENGE_DOMAIN.len() + fingerprint.len() + nonce.len() + 1, + ); + message.extend_from_slice(BOOTSTRAP_CHALLENGE_DOMAIN); + message.extend_from_slice(fingerprint.as_bytes()); + message.push(0); + message.extend_from_slice(nonce.as_bytes()); + hmac::verify(&self.0, &message, &tag).is_ok() + } + + /// Returns a stable, per-user proof that authorizes use of credentials forwarded to a + /// managed sidecar. The HMAC key remains in Relay's private bootstrap state; coding-agent + /// configuration stores only this domain-separated proof. + pub(crate) fn client_token(&self) -> String { + encode_hmac_tag(hmac::sign(&self.0, BOOTSTRAP_CLIENT_TOKEN_DOMAIN)) + } + + pub(crate) fn verify_client_token(&self, token: &str) -> bool { + let Some(encoded) = token.strip_prefix("hmac-sha256:") else { + return false; + }; + let Some(tag) = decode_fixed_hex::<32>(encoded) else { + return false; + }; + hmac::verify(&self.0, BOOTSTRAP_CLIENT_TOKEN_DOMAIN, &tag).is_ok() + } + + #[cfg(test)] + pub(crate) fn from_bytes(bytes: &[u8]) -> Self { + Self(hmac::Key::new(hmac::HMAC_SHA256, bytes)) + } +} + +fn encode_hmac_tag(tag: hmac::Tag) -> String { + format!( + "hmac-sha256:{}", + tag.as_ref() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::() + ) +} + +fn decode_fixed_hex(encoded: &str) -> Option<[u8; N]> { + if encoded.len() != N * 2 || !encoded.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return None; + } + let mut decoded = [0_u8; N]; + for (index, byte) in decoded.iter_mut().enumerate() { + *byte = u8::from_str_radix(&encoded[index * 2..index * 2 + 2], 16).ok()?; + } + Some(decoded) +} + +pub(crate) fn sign_python_environment_attestation( + source_artifact_sha256: &str, + environment_sha256: &str, +) -> Result { + let key = hmac::Key::new(hmac::HMAC_SHA256, &load_or_create_bootstrap_hmac_key()?); + let message = + python_environment_attestation_message(source_artifact_sha256, environment_sha256); + let tag = hmac::sign(&key, &message); + Ok(format!( + "hmac-sha256:{}", + tag.as_ref() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::() + )) +} + +pub(crate) fn verify_python_environment_attestation( + source_artifact_sha256: &str, + environment_sha256: &str, + authentication: &str, +) -> Result { + let Some(encoded) = authentication.strip_prefix("hmac-sha256:") else { + return Ok(false); + }; + let Some(tag) = decode_fixed_hex::<32>(encoded) else { + return Ok(false); + }; + let key = hmac::Key::new(hmac::HMAC_SHA256, &load_or_create_bootstrap_hmac_key()?); + Ok(hmac::verify( + &key, + &python_environment_attestation_message(source_artifact_sha256, environment_sha256), + &tag, + ) + .is_ok()) +} + +fn python_environment_attestation_message( + source_artifact_sha256: &str, + environment_sha256: &str, +) -> Vec { + let mut message = Vec::with_capacity( + PYTHON_ENVIRONMENT_ATTESTATION_DOMAIN.len() + + source_artifact_sha256.len() + + environment_sha256.len() + + 1, + ); + message.extend_from_slice(PYTHON_ENVIRONMENT_ATTESTATION_DOMAIN); + message.extend_from_slice(source_artifact_sha256.trim().as_bytes()); + message.push(0); + message.extend_from_slice(environment_sha256.as_bytes()); + message +} + +fn load_or_create_bootstrap_hmac_key() -> Result<[u8; BOOTSTRAP_HMAC_KEY_BYTES], CliError> { + load_or_create_bootstrap_hmac_key_at(&bootstrap_hmac_key_path()?) +} + +fn bootstrap_hmac_key_path() -> Result { + user_config_dir() + .map(|directory| directory.join("bootstrap").join("fingerprint-hmac.key")) + .ok_or_else(|| { + CliError::Config( + "cannot determine the per-user NeMo Relay bootstrap state directory; set HOME or USERPROFILE" + .into(), + ) + }) +} + +fn load_existing_bootstrap_hmac_key() -> Result, CliError> { + let path = bootstrap_hmac_key_path()?; + let mut file = match OpenOptions::new().read(true).open(&path) { + Ok(file) => file, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => { + return Err(CliError::Config(format!( + "failed to open bootstrap HMAC key {}: {error}", + path.display() + ))); + } + }; + let deadline = Instant::now() + BOOTSTRAP_HMAC_LOCK_TIMEOUT; + loop { + match try_lock_shared(&file) { + Ok(LockAttempt::Acquired) => break, + Ok(LockAttempt::Contended) if Instant::now() < deadline => { + thread::sleep(Duration::from_millis(25)); + } + Ok(LockAttempt::Contended) => { + return Err(CliError::Config(format!( + "timed out waiting for bootstrap HMAC key lock {}", + path.display() + ))); + } + Err(error) => { + return Err(CliError::Config(format!( + "failed to lock bootstrap HMAC key {}: {error}", + path.display() + ))); + } + } + } + let length = file + .metadata() + .map_err(|error| { + CliError::Config(format!( + "failed to inspect bootstrap HMAC key {}: {error}", + path.display() + )) + })? + .len(); + if length != BOOTSTRAP_HMAC_KEY_BYTES as u64 { + return Err(CliError::Config(format!( + "bootstrap HMAC key {} has invalid length {length}; expected {BOOTSTRAP_HMAC_KEY_BYTES} bytes", + path.display() + ))); + } + let mut key = [0_u8; BOOTSTRAP_HMAC_KEY_BYTES]; + file.read_exact(&mut key).map_err(|error| { + CliError::Config(format!( + "failed to read bootstrap HMAC key {}: {error}", + path.display() + )) + })?; + Ok(Some(key)) +} + +fn load_or_create_bootstrap_hmac_key_at( + path: &Path, +) -> Result<[u8; BOOTSTRAP_HMAC_KEY_BYTES], CliError> { + load_or_create_bootstrap_hmac_key_at_with_timeout(path, BOOTSTRAP_HMAC_LOCK_TIMEOUT) +} + +fn load_or_create_bootstrap_hmac_key_at_with_timeout( + path: &Path, + lock_timeout: Duration, +) -> Result<[u8; BOOTSTRAP_HMAC_KEY_BYTES], CliError> { + let parent = path.parent().ok_or_else(|| { + CliError::Config(format!( + "bootstrap HMAC key path {} has no parent directory", + path.display() + )) + })?; + fs::create_dir_all(parent).map_err(|error| { + CliError::Config(format!( + "failed to create bootstrap state directory {}: {error}", + parent.display() + )) + })?; + #[cfg(windows)] + crate::filesystem::protect_private_windows_path(parent).map_err(|error| { + CliError::Config(format!( + "failed to protect bootstrap state directory {}: {error}", + parent.display() + )) + })?; + #[cfg(unix)] + fs::set_permissions(parent, { + use std::os::unix::fs::PermissionsExt; + fs::Permissions::from_mode(0o700) + }) + .map_err(|error| { + CliError::Config(format!( + "failed to protect bootstrap state directory {}: {error}", + parent.display() + )) + })?; + + #[cfg(windows)] + let mut file = crate::filesystem::open_private_windows_file(path).map_err(|error| { + CliError::Config(format!( + "failed to open bootstrap HMAC key {}: {error}", + path.display() + )) + })?; + #[cfg(not(windows))] + let mut file = { + let mut options = OpenOptions::new(); + options.create(true).truncate(false).read(true).write(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + options.open(path).map_err(|error| { + CliError::Config(format!( + "failed to open bootstrap HMAC key {}: {error}", + path.display() + )) + })? + }; + let lock_deadline = Instant::now() + lock_timeout; + loop { + match try_lock_exclusive(&file) { + Ok(LockAttempt::Acquired) => break, + Ok(LockAttempt::Contended) => { + if Instant::now() >= lock_deadline { + return Err(CliError::Config(format!( + "timed out waiting for bootstrap HMAC key lock {}", + path.display() + ))); + } + thread::sleep(Duration::from_millis(25)); + } + Err(error) => { + return Err(CliError::Config(format!( + "failed to lock bootstrap HMAC key {}: {error}", + path.display() + ))); + } + } + } + #[cfg(unix)] + file.set_permissions({ + use std::os::unix::fs::PermissionsExt; + fs::Permissions::from_mode(0o600) + }) + .map_err(|error| { + CliError::Config(format!( + "failed to protect bootstrap HMAC key {}: {error}", + path.display() + )) + })?; + + let length = file + .metadata() + .map_err(|error| { + CliError::Config(format!( + "failed to inspect bootstrap HMAC key {}: {error}", + path.display() + )) + })? + .len(); + if length == 0 { + let mut key = [0_u8; BOOTSTRAP_HMAC_KEY_BYTES]; + SystemRandom::new() + .fill(&mut key) + .map_err(|_| CliError::Config("failed to generate bootstrap HMAC key".into()))?; + file.write_all(&key).map_err(|error| { + CliError::Config(format!( + "failed to write bootstrap HMAC key {}: {error}", + path.display() + )) + })?; + file.sync_all().map_err(|error| { + CliError::Config(format!( + "failed to persist bootstrap HMAC key {}: {error}", + path.display() + )) + })?; + return Ok(key); + } + if length != BOOTSTRAP_HMAC_KEY_BYTES as u64 { + return Err(CliError::Config(format!( + "bootstrap HMAC key {} has invalid length {length}; expected {BOOTSTRAP_HMAC_KEY_BYTES} bytes", + path.display() + ))); + } + file.seek(SeekFrom::Start(0)).map_err(|error| { + CliError::Config(format!( + "failed to read bootstrap HMAC key {}: {error}", + path.display() + )) + })?; + let mut key = [0_u8; BOOTSTRAP_HMAC_KEY_BYTES]; + file.read_exact(&mut key).map_err(|error| { + CliError::Config(format!( + "failed to read bootstrap HMAC key {}: {error}", + path.display() + )) + })?; + Ok(key) +} + +/// Resolves shared config for plugin-facing CLI commands without mutating gateway runtime fields. +pub(crate) fn resolve_plugins_config( + explicit: Option<&PathBuf>, +) -> Result { + load_shared_config(explicit, None) +} + +/// Resolves transparent `run` configuration and switches the gateway to an ephemeral bind address. +/// +/// Explicit run arguments override inherited top-level server flags, which override shared config. +/// Session metadata and plugin config are parsed as JSON here so malformed CLI values fail before +/// the child agent is spawned. +pub(crate) fn resolve_run_config( + command: &RunOverrides, + inherited: Option<&GatewayOverrides>, +) -> Result { + let config = command + .config + .as_ref() + .or_else(|| inherited.and_then(|args| args.config.as_ref())); + let plugin_config_path = command + .plugin_config_path + .as_ref() + .or_else(|| inherited.and_then(|args| args.plugin_config_path.as_ref())); + let mut resolved = load_shared_config(config, plugin_config_path)?; + if let Some(args) = inherited { + apply_server_overrides(&mut resolved.gateway, args)?; + } + apply_run_overrides(&mut resolved.gateway, command)?; + resolved.gateway.bind = "127.0.0.1:0" + .parse() + .expect("valid transparent bind address"); + if !command.dry_run { + enforce_required_dynamic_plugin_startup(config, &resolved)?; + } + Ok(resolved) +} + +// Applies subcommand-specific `run` overrides after inherited top-level flags. JSON-bearing fields +// are parsed here so invalid metadata or plugin config fails before the gateway binds a port. +fn apply_run_overrides(config: &mut GatewayConfig, command: &RunOverrides) -> Result<(), CliError> { + apply_run_url_overrides(config, command); + apply_run_json_overrides(config, command)?; + Ok(()) +} + +// Applies plain string/path run overrides. These fields do not need parsing, so they stay separate +// from JSON options whose errors should include field context. +fn apply_run_url_overrides(config: &mut GatewayConfig, command: &RunOverrides) { + if let Some(value) = &command.openai_base_url { + config.openai_base_url = value.clone(); + } + if let Some(value) = &command.anthropic_base_url { + config.anthropic_base_url = value.clone(); + } +} + +// Parses JSON-bearing run overrides after simple values. Invalid metadata or plugin config fails +// before transparent run mode binds its ephemeral gateway listener. +fn apply_run_json_overrides( + config: &mut GatewayConfig, + command: &RunOverrides, +) -> Result<(), CliError> { + if let Some(value) = &command.session_metadata { + config.metadata = Some(parse_json_option("session metadata", value)?); + } + Ok(()) +} + +// Applies direct server flags on top of already-merged configuration. Only present options mutate +// the config so lower-priority file values survive when a flag was omitted. +fn apply_server_overrides( + config: &mut GatewayConfig, + args: &GatewayOverrides, +) -> Result<(), CliError> { + if let Some(value) = args.bind { + config.bind = value; + } + if let Some(value) = &args.openai_base_url { + config.openai_base_url = value.clone(); + } + if let Some(value) = &args.anthropic_base_url { + config.anthropic_base_url = value.clone(); + } + if let Some(value) = args.max_hook_payload_bytes { + config.max_hook_payload_bytes = validate_body_limit("max hook payload bytes", value)?; + } + if let Some(value) = args.max_passthrough_body_bytes { + config.max_passthrough_body_bytes = + validate_body_limit("max passthrough body bytes", value)?; + } + Ok(()) +} + +pub(crate) const PLUGINS_TOML: &str = "plugins.toml"; + +// Loads config from the ordered shared locations, deep-merges TOML tables, maps the typed file +// shape onto runtime structs, applies a sibling/discovered plugins.toml when present, then lets +// environment variables override file values. Invalid TOML or typed shapes fail closed because +// they indicate an operator configuration error. +fn load_shared_config( + explicit: Option<&PathBuf>, + plugin_config_path: Option<&PathBuf>, +) -> Result { + load_shared_config_scoped(explicit, plugin_config_path, user_config_scope()) +} + +fn load_shared_config_scoped( + explicit: Option<&PathBuf>, + plugin_config_path: Option<&PathBuf>, + user_only: bool, +) -> Result { + let mut merged = toml::Value::Table(toml::map::Map::new()); + for path in config_paths_scoped(explicit, user_only) { + let Some(raw) = read_config_file(&path, explicit.is_some(), "configuration")? else { + continue; + }; + let parsed = raw + .parse::() + .map(toml::Value::Table) + .map_err(|error| { + CliError::Config(format!("invalid TOML in {}: {error}", path.display())) + })?; + let legacy_observability = legacy_observability_sections(&parsed); + if !legacy_observability.is_empty() { + return Err(CliError::Config(format!( + "legacy observability config in {} is no longer supported: {}; configure \ + observability in plugins.toml with `nemo-relay plugins edit`", + path.display(), + legacy_observability.join(", ") + ))); + } + if parsed.get("plugins").is_some() { + return Err(CliError::Config(format!( + "plugin configuration in {} is no longer supported; move it to plugins.toml", + path.display() + ))); + } + merge_toml(&mut merged, parsed); + } + let plugin_toml = load_plugin_toml_config_scoped(explicit, plugin_config_path, user_only)?; + let mut resolved = ResolvedConfig { + gateway: GatewayConfig::default(), + ..ResolvedConfig::default() + }; + apply_file_config(&mut resolved, merged)?; + apply_plugin_toml_config(&mut resolved, plugin_toml); + apply_env_config(&mut resolved.gateway)?; + Ok(resolved) +} + +fn read_config_file( + path: &Path, + required: bool, + description: &str, +) -> Result, CliError> { + match path.try_exists() { + Ok(false) if !required => Ok(None), + Ok(false) => Err(CliError::Config(format!( + "explicit {description} file {} does not exist", + path.display() + ))), + Err(error) => Err(CliError::Config(format!( + "failed to inspect {description} file {}: {error}", + path.display() + ))), + Ok(true) => std::fs::read_to_string(path).map(Some).map_err(|error| { + CliError::Config(format!( + "failed to read {description} file {}: {error}", + path.display() + )) + }), + } +} + +/// Returns true if any of the implicit config file locations exists on disk. Used by the +/// easy-path dispatcher to decide whether to launch setup (no config found) or proceed +/// with config-driven settings. Mirrors `config_paths(None)` but only checks existence. +pub(crate) fn any_config_file_exists() -> bool { + config_paths(None).iter().any(|path| path.exists()) +} + +// Returns the config search path. An explicit path disables implicit discovery; otherwise system +// config is lowest priority, the nearest project config is next, and user config is merged last. +fn config_paths(explicit: Option<&PathBuf>) -> Vec { + config_paths_scoped(explicit, user_config_scope()) +} + +fn config_paths_scoped(explicit: Option<&PathBuf>, user_only: bool) -> Vec { + if let Some(path) = explicit { + return vec![path.clone()]; + } + let mut paths = vec![PathBuf::from("/etc/nemo-relay/config.toml")]; + if !user_only + && let Ok(cwd) = std::env::current_dir() + && let Some(project) = find_project_config(&cwd) + { + paths.push(project); + } + if let Some(user) = user_config_path() { + paths.push(user); + } + paths +} + +// Returns the plugin config search path. An explicit gateway config path scopes plugins.toml to the +// same directory so `--config path/to/config.toml` can be extended by `path/to/plugins.toml` without +// reading unrelated implicit project/user/global plugin files. +fn plugin_config_paths( + explicit: Option<&PathBuf>, + plugin_config_path: Option<&PathBuf>, +) -> Vec { + plugin_config_paths_scoped(explicit, plugin_config_path, user_config_scope()) +} + +fn plugin_config_paths_scoped( + explicit: Option<&PathBuf>, + plugin_config_path: Option<&PathBuf>, + user_only: bool, +) -> Vec { + if let Some(path) = plugin_config_path { + return vec![path.clone()]; + } + if let Some(path) = explicit { + return path + .parent() + .map(|parent| vec![parent.join(PLUGINS_TOML)]) + .unwrap_or_default(); + } + if user_only { + return implicit_plugin_config_paths(None, user_config_dir()); + } + implicit_plugin_config_paths(std::env::current_dir().ok().as_deref(), user_config_dir()) +} + +fn user_config_scope() -> bool { + std::env::var("NEMO_RELAY_CONFIG_SCOPE").ok().as_deref() == Some("user") +} + +/// Returns the implicit `plugins.toml` discovery paths used by the gateway and doctor. +pub(crate) fn default_plugin_config_paths() -> Vec { + plugin_config_paths(None, None) +} + +fn implicit_plugin_config_paths( + cwd: Option<&std::path::Path>, + user_config_dir: Option, +) -> Vec { + // The search-path logic lives in core; the gateway shares it so discovery stays identical. + nemo_relay::plugin::default_plugin_config_paths(cwd, user_config_dir) +} + +// Walks upward from the current directory and returns the nearest project-local gateway config. +// The first hit wins so nested projects can override parent workspace defaults. +fn find_project_config(start: &std::path::Path) -> Option { + for ancestor in start.ancestors() { + let path = ancestor.join(".nemo-relay/config.toml"); + if path.exists() { + return Some(path); + } + } + None +} + +// The project-walk lives in core; the gateway shares it so discovery stays identical. +fn find_project_plugin_config(start: &std::path::Path) -> Option { + nemo_relay::plugin::nearest_project_plugin_config(start) +} + +pub(crate) fn user_plugin_config_path() -> Option { + user_config_dir().map(|dir| dir.join(PLUGINS_TOML)) +} + +pub(crate) fn user_plugin_runtime_config() -> Result, CliError> { + Ok( + load_plugin_toml_config_from_paths(implicit_plugin_config_paths(None, user_config_dir()))? + .and_then(|config| config.value), + ) +} + +pub(crate) fn project_plugin_config_path(start: &std::path::Path) -> PathBuf { + find_project_plugin_config(start) + .or_else(|| { + find_project_config(start) + .and_then(|path| path.parent().map(|parent| parent.join(PLUGINS_TOML))) + }) + .unwrap_or_else(|| start.join(".nemo-relay").join(PLUGINS_TOML)) +} + +pub(crate) fn global_plugin_config_path() -> PathBuf { + PathBuf::from("/etc/nemo-relay").join(PLUGINS_TOML) +} + +// Resolves the user config using XDG first and HOME/USERPROFILE second. Returning `None` keeps +// config loading portable in minimal environments where no home directory is visible. +fn user_config_path() -> Option { + user_config_dir().map(|dir| dir.join("config.toml")) +} + +/// Resolves the nemo-relay user config DIRECTORY (without trailing filename). Delegates to core's +/// resolver so the gateway, the editor, and the plugin runtime agree on the location. +pub(crate) fn user_config_dir() -> Option { + nemo_relay::plugin::user_config_dir() +} + +// Applies the typed TOML config model to the resolved runtime config. Missing sections and fields +// are ignored, preserving defaults and prior merge layers. +fn apply_file_config(resolved: &mut ResolvedConfig, value: toml::Value) -> Result<(), CliError> { + let config: FileConfig = value.try_into().map_err(|error| { + CliError::Config(format!("invalid gateway configuration shape: {error}")) + })?; + apply_file_gateway_config(&mut resolved.gateway, config.gateway)?; + apply_file_upstream_config(&mut resolved.gateway, config.upstream); + apply_file_agents_config(&mut resolved.agents, config.agents); + Ok(()) +} + +fn apply_file_gateway_config( + gateway: &mut GatewayConfig, + config: Option, +) -> Result<(), CliError> { + let Some(config) = config else { + return Ok(()); + }; + if let Some(value) = config.max_hook_payload_bytes { + gateway.max_hook_payload_bytes = + validate_body_limit("gateway.max_hook_payload_bytes", value)?; + } + if let Some(value) = config.max_passthrough_body_bytes { + gateway.max_passthrough_body_bytes = + validate_body_limit("gateway.max_passthrough_body_bytes", value)?; + } + Ok(()) +} + +// Applies upstream LLM provider URLs. These are the bases for OpenAI- and Anthropic-shaped +// gateway routes; transparent `run` mode can still override them per invocation. +fn apply_file_upstream_config(gateway: &mut GatewayConfig, upstream: Option) { + let Some(upstream) = upstream else { + return; + }; + if let Some(value) = upstream.openai_base_url { + gateway.openai_base_url = value; + } + if let Some(value) = upstream.anthropic_base_url { + gateway.anthropic_base_url = value; + } +} + +#[derive(Debug, Clone)] +struct PluginTomlConfig { + value: Option, + dynamic_plugins: Vec, + dynamic_plugin_policy: DynamicPluginHostPolicy, + contributing_sources: Vec, +} + +#[derive(Debug, Clone, Default, Deserialize)] +struct PluginTomlPluginsSection { + #[serde(default)] + dynamic: Vec, + #[serde(default)] + policy: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +struct FileDynamicPluginConfig { + manifest: String, + #[serde(default)] + config: Option>, +} + +fn load_plugin_toml_config( + explicit: Option<&PathBuf>, + plugin_config_path: Option<&PathBuf>, +) -> Result, CliError> { + load_plugin_toml_config_scoped(explicit, plugin_config_path, user_config_scope()) +} + +fn load_plugin_toml_config_scoped( + explicit: Option<&PathBuf>, + plugin_config_path: Option<&PathBuf>, + user_only: bool, +) -> Result, CliError> { + load_plugin_toml_config_from_paths(plugin_config_paths_scoped( + explicit, + plugin_config_path, + user_only, + )) +} + +/// Returns the physical `plugins.toml` files that contribute effective runtime or dynamic +/// plugin configuration under the default discovery rules. +pub(crate) fn effective_plugin_toml_sources() -> Result, CliError> { + let Some(config) = load_plugin_toml_config(None, None)? else { + return Ok(Vec::new()); + }; + let mut sources = config.contributing_sources; + sources.sort(); + sources.dedup(); + Ok(sources) +} + +fn load_plugin_toml_config_from_paths(paths: I) -> Result, CliError> +where + I: IntoIterator, +{ + let paths = paths.into_iter().collect::>(); + let mut dynamic_plugins = Vec::new(); + let mut dynamic_plugin_policy = DynamicPluginHostPolicy::default(); + let mut seen_plugin_ids = HashSet::new(); + let mut contributing_sources = Vec::new(); + let mut runtime_documents = Vec::new(); + + for path in &paths { + let Some(raw) = read_config_file(path, false, "plugin configuration")? else { + continue; + }; + let mut parsed = raw + .parse::() + .map(toml::Value::Table) + .map_err(|error| { + CliError::Config(format!( + "invalid plugin TOML in {}: {error}", + path.display() + )) + })?; + let resolved_plugins = + resolve_dynamic_plugin_refs(path, &mut parsed, &mut seen_plugin_ids)?; + if !resolved_plugins.dynamic_plugins.is_empty() + || resolved_plugins.dynamic_plugin_policy != DynamicPluginHostPolicy::default() + { + contributing_sources.push(path.clone()); + } + dynamic_plugins.extend(resolved_plugins.dynamic_plugins); + dynamic_plugin_policy.merge_from(resolved_plugins.dynamic_plugin_policy); + runtime_documents.push(( + path.clone(), + serde_json::to_value(remove_dynamic_plugin_sections(parsed)) + .expect("toml value serializes to JSON"), + )); + } + + // Delegate merged runtime plugin config to the shared core primitive after dynamic refs have + // been validated independently. File precedence stays unchanged for the generic runtime path. + let resolved = merge_plugin_config_documents(runtime_documents).map_err(|err| match err { + PluginError::InvalidConfig(message) => CliError::Config(message), + other => CliError::Config(other.to_string()), + })?; + match resolved { + Some((value, sources)) => { + contributing_sources.extend(sources.iter().cloned()); + contributing_sources.sort(); + contributing_sources.dedup(); + Ok(Some(PluginTomlConfig { + value: plugin_toml_runtime_value(value), + dynamic_plugins, + dynamic_plugin_policy, + contributing_sources, + })) + } + None => Ok((!dynamic_plugins.is_empty() + || dynamic_plugin_policy != DynamicPluginHostPolicy::default()) + .then_some(PluginTomlConfig { + value: None, + dynamic_plugins, + dynamic_plugin_policy, + contributing_sources, + })), + } +} + +fn apply_plugin_toml_config(resolved: &mut ResolvedConfig, plugin_toml: Option) { + let Some(plugin_toml) = plugin_toml else { + return; + }; + if let Some(value) = plugin_toml.value { + resolved.gateway.plugin_config = Some(value); + } + resolved.dynamic_plugins = plugin_toml.dynamic_plugins; + resolved.dynamic_plugin_policy = plugin_toml.dynamic_plugin_policy; +} + +struct ResolvedDynamicPluginRefs { + dynamic_plugins: Vec, + dynamic_plugin_policy: DynamicPluginHostPolicy, +} + +fn resolve_dynamic_plugin_refs( + source: &Path, + value: &mut toml::Value, + seen_plugin_ids: &mut HashSet, +) -> Result { + let Some(root) = value.as_table_mut() else { + return Ok(ResolvedDynamicPluginRefs { + dynamic_plugins: Vec::new(), + dynamic_plugin_policy: DynamicPluginHostPolicy::default(), + }); + }; + + let plugins_value = root.get("plugins").cloned(); + let Some(plugins_value) = plugins_value else { + return Ok(ResolvedDynamicPluginRefs { + dynamic_plugins: Vec::new(), + dynamic_plugin_policy: DynamicPluginHostPolicy::default(), + }); + }; + + let plugins: PluginTomlPluginsSection = plugins_value.try_into().map_err(|error| { + CliError::Config(format!( + "invalid dynamic plugin config in {}: {error}", + source.display() + )) + })?; + + if let Some(toml::Value::Table(plugins_table)) = root.get_mut("plugins") { + plugins_table.remove("dynamic"); + plugins_table.remove("policy"); + if plugins_table.is_empty() { + root.remove("plugins"); + } + } + + let mut resolved = Vec::with_capacity(plugins.dynamic.len()); + for dynamic in plugins.dynamic { + let manifest_path = resolve_dynamic_manifest_path(source, &dynamic.manifest); + let (manifest, manifest_ref) = load_bounded_dynamic_plugin_manifest(&manifest_path) + .map_err(|error| { + CliError::Config(format!( + "invalid dynamic plugin manifest referenced by {}: {error}", + source.display() + )) + })?; + let plugin_id = manifest.plugin.id.trim().to_owned(); + if !seen_plugin_ids.insert(plugin_id.clone()) { + return Err(CliError::Config(format!( + "duplicate dynamic plugin id '{}' in {} across plugins.toml sources", + plugin_id, + source.display() + ))); + } + resolved.push(ResolvedDynamicPluginConfig { + plugin_id, + manifest_ref, + has_explicit_config: dynamic.config.is_some(), + config: dynamic.config.unwrap_or_default(), + source: source.to_path_buf(), + }); + } + Ok(ResolvedDynamicPluginRefs { + dynamic_plugins: resolved, + dynamic_plugin_policy: plugins.policy.map(Into::into).unwrap_or_default(), + }) +} + +fn resolve_dynamic_manifest_path(source: &Path, manifest: &str) -> PathBuf { + let manifest = PathBuf::from(manifest); + if manifest.is_absolute() { + manifest + } else { + source + .parent() + .map(|parent| parent.join(&manifest)) + .unwrap_or(manifest) + } +} + +fn plugin_toml_runtime_value(value: Value) -> Option { + match value { + Value::Object(ref object) if object.is_empty() => None, + other => Some(other), + } +} + +fn remove_dynamic_plugin_sections(mut value: toml::Value) -> toml::Value { + if let Some(root) = value.as_table_mut() + && let Some(toml::Value::Table(plugins)) = root.get_mut("plugins") + { + plugins.remove("dynamic"); + plugins.remove("policy"); + if plugins.is_empty() { + root.remove("plugins"); + } + } + value +} + +// Applies configured agent commands from the merged file configuration. +fn apply_file_agents_config(agents: &mut AgentConfigs, file_agents: Option) { + let Some(file_agents) = file_agents else { + return; + }; + if let Some(value) = file_agents.claude { + agents.claude.command = value.command; + } + if let Some(value) = file_agents.codex { + agents.codex.command = value.command; + } + if let Some(value) = file_agents.hermes { + agents.hermes.command = value.command; + agents.hermes.hooks_path = value.hooks_path; + } +} + +// Applies environment variables after file configuration. Invalid bind values are ignored here to +// preserve existing startup behavior, while string values replace earlier layers when present. +fn apply_env_config(config: &mut GatewayConfig) -> Result<(), CliError> { + if let Ok(value) = std::env::var("NEMO_RELAY_GATEWAY_BIND") + && let Ok(value) = value.parse() + { + config.bind = value; + } + if let Ok(value) = std::env::var("NEMO_RELAY_OPENAI_BASE_URL") { + config.openai_base_url = value; + } + if let Ok(value) = std::env::var("NEMO_RELAY_ANTHROPIC_BASE_URL") { + config.anthropic_base_url = value; + } + if let Ok(value) = std::env::var("NEMO_RELAY_MAX_HOOK_PAYLOAD_BYTES") { + config.max_hook_payload_bytes = + parse_env_body_limit("NEMO_RELAY_MAX_HOOK_PAYLOAD_BYTES", &value)?; + } + if let Ok(value) = std::env::var("NEMO_RELAY_MAX_PASSTHROUGH_BODY_BYTES") { + config.max_passthrough_body_bytes = + parse_env_body_limit("NEMO_RELAY_MAX_PASSTHROUGH_BODY_BYTES", &value)?; + } + Ok(()) +} + +fn parse_env_body_limit(name: &str, raw: &str) -> Result { + let value = raw.parse::().map_err(|error| { + CliError::Config(format!("{name} must be a positive byte count: {error}")) + })?; + validate_body_limit(name, value) +} + +fn validate_body_limit(name: &str, value: usize) -> Result { + if value == 0 { + return Err(CliError::Config(format!("{name} must be greater than 0"))); + } + Ok(value) +} + +// Recursively merges TOML tables and replaces scalar/array values from the higher-priority side. +// This lets user/project configs override individual nested keys without restating whole sections. +fn merge_toml(left: &mut toml::Value, right: toml::Value) { + match (left, right) { + (toml::Value::Table(left), toml::Value::Table(right)) => { + for (key, value) in right { + match left.get_mut(&key) { + Some(existing) => merge_toml(existing, value), + None => { + left.insert(key, value); + } + } + } + } + (left, right) => *left = right, + } +} + +fn legacy_observability_sections(value: &toml::Value) -> Vec<&'static str> { + let mut sections = Vec::new(); + if value.get("exporters").is_some() { + sections.push("[exporters]"); + } + if value.get("observability").is_some() { + sections.push("[observability]"); + } + if value + .get("export") + .and_then(|export| export.get("openinference")) + .is_some() + { + sections.push("[export.openinference]"); + } + sections +} + +// Parses JSON-valued CLI options into runtime metadata/config values and labels errors with the +// user-facing option name so callers can report which structured argument was malformed. +fn parse_json_option(name: &str, value: &str) -> Result { + serde_json::from_str::(value) + .map_err(|error| CliError::Config(format!("invalid {name}: {error}"))) +} + +/// Reads a non-empty UTF-8 header value as an owned string. +/// +/// Invalid header bytes and empty strings are treated as absent so callers can preserve their +/// explicit fallback order without surfacing HTTP parsing details as gateway errors. +pub(crate) fn header_string(headers: &HeaderMap, name: &str) -> Option { + headers + .get(name) + .and_then(|value| value.to_str().ok()) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) +} + +fn header_json(headers: &HeaderMap, name: &str) -> Option { + header_string(headers, name).and_then(|raw| serde_json::from_str(&raw).ok()) +} + +#[cfg(test)] +#[path = "../../tests/coverage/shared/config_tests.rs"] +mod tests; diff --git a/crates/cli/src/configuration/types.rs b/crates/cli/src/configuration/types.rs new file mode 100644 index 000000000..615ef1875 --- /dev/null +++ b/crates/cli/src/configuration/types.rs @@ -0,0 +1,120 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Resolved runtime configuration model. + +use std::net::SocketAddr; +use std::path::PathBuf; + +use axum::http::HeaderMap; +use serde::Serialize; +use serde_json::{Map, Value}; +use strum::{Display, IntoStaticStr}; + +use crate::plugins::policy::DynamicPluginHostPolicy; + +use super::{ + DEFAULT_MAX_HOOK_PAYLOAD_BYTES, DEFAULT_MAX_PASSTHROUGH_BODY_BYTES, header_json, header_string, +}; + +#[derive(Debug, Clone)] +pub(crate) struct GatewayConfig { + pub(crate) bind: SocketAddr, + pub(crate) openai_base_url: String, + pub(crate) anthropic_base_url: String, + pub(crate) metadata: Option, + pub(crate) plugin_config: Option, + pub(crate) max_hook_payload_bytes: usize, + pub(crate) max_passthrough_body_bytes: usize, +} + +#[derive(Debug, Clone, Default)] +pub(crate) struct SessionConfig { + pub(crate) metadata: Option, + pub(crate) plugin_config: Option, + pub(crate) profile: Option, + pub(crate) gateway_mode: Option, +} + +impl GatewayConfig { + pub(crate) fn session_config_from_headers(&self, headers: &HeaderMap) -> SessionConfig { + let metadata = + header_json(headers, "x-nemo-relay-session-metadata").or_else(|| self.metadata.clone()); + let plugin_config = header_json(headers, "x-nemo-relay-plugin-config") + .or_else(|| self.plugin_config.clone()); + let profile = header_string(headers, "x-nemo-relay-config-profile"); + let gateway_mode = header_string(headers, "x-nemo-relay-gateway-mode"); + SessionConfig { + metadata, + plugin_config, + profile, + gateway_mode, + } + } +} + +#[derive(Debug, Clone, Default)] +pub(crate) struct ResolvedConfig { + pub(crate) gateway: GatewayConfig, + pub(crate) agents: AgentConfigs, + pub(crate) dynamic_plugins: Vec, + pub(crate) dynamic_plugin_policy: DynamicPluginHostPolicy, + pub(crate) bootstrap_fingerprint: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ResolvedDynamicPluginConfig { + pub(crate) plugin_id: String, + pub(crate) manifest_ref: String, + pub(crate) config: Map, + pub(crate) has_explicit_config: bool, + pub(crate) source: PathBuf, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Display, IntoStaticStr)] +#[serde(rename_all = "snake_case")] +#[strum(serialize_all = "snake_case")] +pub(crate) enum DynamicPluginHostConfigStatus { + Absent, + Present, +} + +impl ResolvedDynamicPluginConfig { + pub(crate) fn host_config_status(&self) -> DynamicPluginHostConfigStatus { + if self.has_explicit_config { + DynamicPluginHostConfigStatus::Present + } else { + DynamicPluginHostConfigStatus::Absent + } + } +} + +#[derive(Debug, Clone, Default)] +pub(crate) struct AgentConfigs { + pub(crate) claude: AgentCommandConfig, + pub(crate) codex: AgentCommandConfig, + pub(crate) hermes: AgentCommandConfig, +} + +#[derive(Debug, Clone, Default)] +pub(crate) struct AgentCommandConfig { + pub(crate) command: Option, + /// Legacy Hermes config-path override retained for existing Relay configuration files. + pub(crate) hooks_path: Option, +} + +impl Default for GatewayConfig { + fn default() -> Self { + Self { + bind: "127.0.0.1:4040" + .parse() + .expect("valid default bind address"), + openai_base_url: "https://api.openai.com/v1".into(), + anthropic_base_url: "https://api.anthropic.com".into(), + metadata: None, + plugin_config: None, + max_hook_payload_bytes: DEFAULT_MAX_HOOK_PAYLOAD_BYTES, + max_passthrough_body_bytes: DEFAULT_MAX_PASSTHROUGH_BODY_BYTES, + } + } +} diff --git a/crates/cli/src/diagnostics/environment.rs b/crates/cli/src/diagnostics/environment.rs new file mode 100644 index 000000000..69efeb178 --- /dev/null +++ b/crates/cli/src/diagnostics/environment.rs @@ -0,0 +1,35 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Process and operating-system diagnostic collection. + +use super::EnvironmentInfo; + +pub(super) fn collect_environment() -> EnvironmentInfo { + let version = os_version(); + let os = if version.is_empty() { + std::env::consts::OS.to_string() + } else { + format!("{} {version}", std::env::consts::OS) + }; + let shell_variable = if cfg!(windows) { "COMSPEC" } else { "SHELL" }; + EnvironmentInfo { + os, + arch: std::env::consts::ARCH, + shell: std::env::var(shell_variable).ok().and_then(|path| { + std::path::Path::new(&path) + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + }), + } +} + +fn os_version() -> String { + if cfg!(windows) { + return String::new(); + } + match std::process::Command::new("uname").arg("-r").output() { + Ok(out) if out.status.success() => String::from_utf8_lossy(&out.stdout).trim().to_string(), + _ => String::new(), + } +} diff --git a/crates/cli/src/doctor.rs b/crates/cli/src/diagnostics/mod.rs similarity index 60% rename from crates/cli/src/doctor.rs rename to crates/cli/src/diagnostics/mod.rs index dee7d4d93..a5b7ab736 100644 --- a/crates/cli/src/doctor.rs +++ b/crates/cli/src/diagnostics/mod.rs @@ -9,8 +9,17 @@ //! - `DoctorReport` is the resulting pure data shape. //! - `format_human(&report)` / `format_json(&report)` render the report. +mod environment; +mod model; +mod probes; +mod render; + +use environment::collect_environment; +pub(crate) use model::*; +use probes::*; +use render::*; + use std::path::{Path, PathBuf}; -use std::process::Stdio; use std::time::Duration; use futures_util::SinkExt; @@ -18,124 +27,35 @@ use nemo_relay::api::event::{BaseEvent, Event, MarkEvent}; use nemo_relay::codec::model_pricing::{PricingCatalog, PricingConfig, PricingSourceConfig}; use nemo_relay::observability::plugin_component::OBSERVABILITY_PLUGIN_KIND; use nemo_relay::plugin::{DiagnosticLevel, PluginConfig, validate_plugin_config}; -use nemo_relay_adaptive::plugin_component::register_adaptive_component; -use nemo_relay_pii_redaction::component::register_pii_redaction_component; -#[cfg(feature = "switchyard")] -use nemo_relay_switchyard::{ - register_switchyard_component, validate_switchyard_atof_configuration, -}; -use serde::Serialize; use serde_json::{Value, json}; use tokio::time::timeout; use tokio_tungstenite::tungstenite::client::IntoClientRequest; use uuid::Uuid; -use crate::config::{ - AgentConfigs, CodingAgent, DynamicPluginHostConfigStatus, GatewayConfig, ResolvedConfig, - ServerArgs, default_plugin_config_paths, effective_plugin_toml_sources, resolve_server_config, +use crate::agents::CodingAgent; +use crate::configuration::{ + AgentConfigs, DynamicPluginHostConfigStatus, GatewayConfig, ResolvedConfig, + default_plugin_config_paths, effective_plugin_toml_sources, resolve_server_config, }; use crate::error::CliError; +use crate::server::{GatewayOverrides, register_and_validate_plugin_components}; const NETWORK_TIMEOUT: Duration = Duration::from_secs(2); const PRICING_PLUGIN_KIND: &str = "pricing"; -/// Outcome of one check inside the doctor report. The `details` field carries human-readable -/// supplementary text; the `status` is the bottom-line signal callers (and CI) use to decide -/// pass/fail. -#[derive(Debug, Clone, Serialize, PartialEq, Eq)] -pub(crate) struct Check { - pub name: &'static str, - pub status: Status, - pub details: String, -} - -#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)] -#[serde(rename_all = "lowercase")] -pub(crate) enum Status { - Pass, - Warn, - Fail, - /// The check ran but no relevant state was detected — purely informational (e.g. an agent - /// not on $PATH). Renders as a dot; not counted toward exit code. - Info, -} - -/// Snapshot of the running system that the doctor renders. Stable schema, versioned via -/// `schema_version`. Adding fields is non-breaking; removing or renaming requires a bump. -#[derive(Debug, Clone, Serialize)] -pub(crate) struct DoctorReport { - pub schema_version: u32, - pub binary_version: &'static str, - pub target_agent: Option, - pub environment: EnvironmentInfo, - pub configuration: ConfigurationInfo, - pub agents: Vec, - pub host_plugins: Vec, - pub observability: Vec, - pub completions: Vec, -} - -#[derive(Debug, Clone, Serialize)] -pub(crate) struct EnvironmentInfo { - pub os: String, - pub arch: &'static str, - pub shell: Option, -} - -#[derive(Debug, Clone, Serialize)] -pub(crate) struct ConfigurationInfo { - pub workspace: ConfigLayer, - pub global: ConfigLayer, - pub system: ConfigLayer, - pub plugin_configs: Vec, - pub plugin_resolution: Check, - pub resolution: Check, - pub default_agent: Option, - pub configured_agents: Vec, - pub dynamic_plugins: Vec, -} - struct PluginConfigurationDiagnostics { sources: Vec, error: Option, resolution: Check, } -#[derive(Debug, Clone, Serialize)] -pub(crate) struct DynamicPluginReferenceInfo { - pub plugin_id: String, - pub manifest_ref: String, - pub source: PathBuf, - pub host_config_status: DynamicPluginHostConfigStatus, -} - -#[derive(Debug, Clone, Serialize)] -pub(crate) struct ConfigLayer { - pub path: PathBuf, - pub status: Status, - pub active: bool, - pub details: String, -} - -#[derive(Debug, Clone, Serialize)] -pub(crate) struct AgentInfo { - pub name: &'static str, - pub status: Status, - pub configured: bool, - pub command: String, - pub path: Option, - pub version: Option, - /// Free-form annotation, e.g. "hooks: installed" once we wire up hook detection. - pub annotation: String, -} - /// Drives all checks and produces a single `DoctorReport`. Network probes are bounded by a /// short timeout so the command always returns quickly. Filesystem checks short-circuit on /// the first missing directory. pub(crate) async fn collect_report( target_agent: Option, ) -> Result { - let (resolved, resolution) = match resolve_server_config(&ServerArgs::default()) { + let (resolved, resolution) = match resolve_server_config(&GatewayOverrides::default()) { Ok(resolved) => ( resolved, Check { @@ -182,41 +102,18 @@ pub(crate) async fn collect_report( &plugin_diagnostics, ), agents: collect_agents(target_agent, &resolved).await, - host_plugins: crate::plugin_install::collect_default_host_plugin_readiness(), + host_plugins: crate::agents::collect_default_integration_readiness(), observability: collect_observability(&resolved.gateway).await, completions: collect_completions(home.as_deref()), }) } -fn collect_environment() -> EnvironmentInfo { - EnvironmentInfo { - os: format!("{} {}", std::env::consts::OS, os_version()), - arch: std::env::consts::ARCH, - shell: std::env::var("SHELL").ok().and_then(|path| { - std::path::Path::new(&path) - .file_name() - .map(|name| name.to_string_lossy().into_owned()) - }), - } -} - -fn os_version() -> String { - // `uname -r` works on macOS/Linux; on Windows we just report the OS name with no detail. - if cfg!(windows) { - return String::new(); - } - match std::process::Command::new("uname").arg("-r").output() { - Ok(out) if out.status.success() => String::from_utf8_lossy(&out.stdout).trim().to_string(), - _ => String::new(), - } -} - fn collect_configuration( cwd: Option<&Path>, home: Option<&Path>, resolution: Check, configured_agents: Vec, - dynamic_plugins: &[crate::config::ResolvedDynamicPluginConfig], + dynamic_plugins: &[crate::configuration::ResolvedDynamicPluginConfig], plugin_diagnostics: &PluginConfigurationDiagnostics, ) -> ConfigurationInfo { let workspace_path = cwd @@ -224,7 +121,7 @@ fn collect_configuration( .unwrap_or_else(|| PathBuf::from(".nemo-relay/config.toml")); // Use the same XDG-aware resolver the config loader uses, so doctor reports the path the // runtime would actually read instead of a hard-coded `$HOME/.config/nemo-relay`. - let global_path = crate::config::user_config_dir() + let global_path = crate::configuration::user_config_dir() .map(|dir| dir.join("config.toml")) .or_else(|| home.map(|h| h.join(".config").join("nemo-relay").join("config.toml"))) .unwrap_or_else(|| PathBuf::from("~/.config/nemo-relay/config.toml")); @@ -397,28 +294,26 @@ async fn collect_agents( target_agent: Option, resolved: &ResolvedConfig, ) -> Vec { - let supported = [ - (CodingAgent::ClaudeCode, "claude", "claude"), - (CodingAgent::Codex, "codex", "codex"), - (CodingAgent::Hermes, "hermes", "hermes"), - ]; - let mut out = Vec::with_capacity(supported.len()); - for (agent, display_name, default_exec) in supported { + let mut out = Vec::with_capacity(CodingAgent::ALL.len()); + for agent in CodingAgent::ALL { if target_agent.is_some_and(|target| target != agent) { continue; } let configured = agent_configured(agent, &resolved.agents); let target_requested = target_agent == Some(agent); - let command = agent_command(agent, &resolved.agents, default_exec); - let exec = command_executable(&command); - let path = which_command(exec); + let command = agent_command(agent, &resolved.agents); + let argv = crate::process::command_argv(&command); + let exec = argv.first().map(String::as_str).unwrap_or_default(); + let path = crate::process::resolve_executable(exec); let version = match &path { - Some(p) => probe_version(p).await, + Some(_) => { + let probe = crate::process::version_probe_argv(agent, &argv); + probe_version(&probe).await + } None => None, }; let mut status = agent_command_status(path.as_deref(), configured, target_requested); - let (hook_status, hook_details) = - hook_status(agent, &resolved.agents, configured || target_requested); + let (hook_status, hook_details) = hook_status(agent, &resolved.agents); status = combine_status(status, hook_status, configured || target_requested); let mut details = Vec::new(); details.push(if configured { @@ -434,14 +329,41 @@ async fn collect_agents( if !hook_details.is_empty() { details.push(hook_details); } - if agent == CodingAgent::ClaudeCode - && let Some(warning) = version.as_deref().and_then(claude_hook_floor_warning) - { - status = combine_status(status, Status::Warn, true); - details.push(warning); + let version_required = configured || target_requested; + match version.as_deref() { + Some(version) => { + if let Err(error) = agent.validate_version_output(version) { + status = combine_status( + status, + if version_required { + Status::Fail + } else { + Status::Warn + }, + true, + ); + details.push(error); + } + } + None if path.is_some() => { + status = combine_status( + status, + if version_required { + Status::Fail + } else { + Status::Warn + }, + true, + ); + details.push(format!( + "could not determine version; NeMo Relay requires {}", + agent.version_requirement() + )); + } + None => {} } out.push(AgentInfo { - name: display_name, + name: agent.as_arg(), status, configured, command, @@ -453,53 +375,25 @@ async fn collect_agents( out } -fn which_on_path(exec: &str) -> Option { - let path_var = std::env::var_os("PATH")?; - std::env::split_paths(&path_var) - .map(|dir| dir.join(exec)) - .find(|candidate| candidate.is_file()) -} - -fn which_command(exec: &str) -> Option { - let candidate = Path::new(exec); - if candidate.components().count() > 1 || candidate.is_absolute() { - return candidate.is_file().then(|| candidate.to_path_buf()); - } - which_on_path(exec) -} - -fn command_executable(command: &str) -> &str { - command.split_whitespace().next().unwrap_or(command) -} - -fn agent_command(agent: CodingAgent, agents: &AgentConfigs, default_exec: &str) -> String { +fn agent_command(agent: CodingAgent, agents: &AgentConfigs) -> String { configured_agent_command(agent, agents) .cloned() - .unwrap_or_else(|| default_exec.to_string()) + .unwrap_or_else(|| agent.executable().to_string()) } fn configured_agent_command(agent: CodingAgent, agents: &AgentConfigs) -> Option<&String> { - match agent { - CodingAgent::ClaudeCode => agents.claude.command.as_ref(), - CodingAgent::Codex => agents.codex.command.as_ref(), - CodingAgent::Hermes => agents.hermes.command.as_ref(), - } + crate::agents::config(agent, agents).command.as_ref() } fn agent_configured(agent: CodingAgent, agents: &AgentConfigs) -> bool { - configured_agent_command(agent, agents).is_some() - || (matches!(agent, CodingAgent::Hermes) && agents.hermes.hooks_path.is_some()) + crate::agents::configured(agent, agents) } fn configured_agent_names(agents: &AgentConfigs) -> Vec { - [ - (CodingAgent::ClaudeCode, "claude"), - (CodingAgent::Codex, "codex"), - (CodingAgent::Hermes, "hermes"), - ] - .into_iter() - .filter_map(|(agent, name)| agent_configured(agent, agents).then_some(name.to_string())) - .collect() + CodingAgent::ALL + .into_iter() + .filter_map(|agent| agent_configured(agent, agents).then_some(agent.as_arg().to_string())) + .collect() } fn agent_command_status(path: Option<&Path>, configured: bool, target_requested: bool) -> Status { @@ -521,110 +415,20 @@ fn combine_status(base: Status, hook: Status, readiness_required: bool) -> Statu base } -fn hook_status( - agent: CodingAgent, - agents: &AgentConfigs, - readiness_required: bool, -) -> (Status, String) { - match agent { - CodingAgent::ClaudeCode | CodingAgent::Codex => { - (Status::Pass, "hooks: injected during run".into()) - } - CodingAgent::Hermes => match agents.hermes.hooks_path.as_deref() { - Some(path) => hook_file_status( - Ok(path.to_path_buf()), - CodingAgent::Hermes, - readiness_required, - "hooks", - ), - None if readiness_required => ( - Status::Fail, - "hooks: not installed; run `nemo-relay config hermes`".into(), - ), - None => (Status::Info, "hooks: not configured".into()), - }, +fn hook_status(agent: CodingAgent, agents: &AgentConfigs) -> (Status, String) { + match crate::agents::hook_status(agent, agents) { + Ok(details) => (Status::Pass, details), + Err(details) => (Status::Fail, details), } } -fn hook_file_status( - path: Result, - agent: CodingAgent, - readiness_required: bool, - label: &str, -) -> (Status, String) { - let path = match path { - Ok(path) => path, - Err(err) => { - return ( - Status::Fail, - format!("{label}: could not resolve path: {err}"), - ); - } - }; - match std::fs::read_to_string(&path) { - Ok(raw) if raw.contains(&format!("hook-forward {}", agent.as_arg())) => ( - Status::Pass, - format!("{label}: installed at {}", path.display()), - ), - Ok(_) if readiness_required => ( - Status::Fail, - format!("{label}: missing NeMo Relay hook in {}", path.display()), - ), - Ok(_) => ( - Status::Info, - format!("{label}: no NeMo Relay hook in {}", path.display()), - ), - Err(error) if error.kind() == std::io::ErrorKind::NotFound && readiness_required => { - (Status::Fail, format!("{label}: missing {}", path.display())) - } - Err(error) if error.kind() == std::io::ErrorKind::NotFound => { - (Status::Info, format!("{label}: missing {}", path.display())) - } - Err(error) => ( - Status::Fail, - format!("{label}: could not read {}: {error}", path.display()), - ), - } -} - -// Claude Code validates plugin hooks.json against a strict event-name whitelist and rejects the -// entire plugin's hooks on one unknown name. 2.1.116 is the oldest release that accepts every -// event in the generated hook config (`UserPromptExpansion` was added to the whitelist there), -// so older hosts silently load no relay hooks at all. Keep in sync with `HOOK_EVENTS` in -// installer.rs. -const CLAUDE_HOOK_EVENT_FLOOR: (u64, u64, u64) = (2, 1, 116); - -// Returns a doctor warning when a probed Claude Code version predates the hook-event floor. -// Unparseable version strings return None: a missing warning is recoverable, a false one is not. -fn claude_hook_floor_warning(version: &str) -> Option { - let parsed = parse_leading_semver(version)?; - (parsed < CLAUDE_HOOK_EVENT_FLOOR).then(|| { - let (major, minor, patch) = CLAUDE_HOOK_EVENT_FLOOR; - format!( - "version predates {major}.{minor}.{patch}; this Claude Code rejects \ - UserPromptExpansion and will silently load no relay hooks" - ) - }) -} - -// Parses the leading `major.minor.patch` token from a probed version line such as -// "2.1.206 (Claude Code)". Suffixes like prerelease tags fail the numeric parse and yield None. -fn parse_leading_semver(version: &str) -> Option<(u64, u64, u64)> { - let mut parts = version.split_whitespace().next()?.splitn(3, '.'); - let major = parts.next()?.parse().ok()?; - let minor = parts.next()?.parse().ok()?; - let patch = parts.next()?.parse().ok()?; - Some((major, minor, patch)) -} - -async fn probe_version(binary: &Path) -> Option { - // Spawn ` --version` and read the first line of stdout. Bounded by the network +async fn probe_version(argv: &[String]) -> Option { + // Run the shared wrapper-preserving probe and read the first line of stdout. Bounded by the network // timeout (re-used as a generic short timeout) so a misbehaving binary doesn't hang doctor. - let mut cmd = tokio::process::Command::new(binary); - cmd.arg("--version") - .stdout(Stdio::piped()) - .stderr(Stdio::null()) - .stdin(Stdio::null()) + let mut cmd = crate::process::tokio_command(argv); + cmd.stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::null()) + .stdin(std::process::Stdio::null()) // Ensure the child gets killed if our future is dropped on timeout. Without this a // misbehaving agent binary that exceeds NETWORK_TIMEOUT would leak as an orphan // process for the lifetime of the doctor invocation (and beyond). @@ -634,6 +438,9 @@ async fn probe_version(binary: &Path) -> Option { .await .ok()? .ok()?; + if !output.status.success() { + return None; + } let stdout = String::from_utf8_lossy(&output.stdout); let first_line = stdout.lines().next()?.trim(); if first_line.is_empty() { @@ -666,38 +473,13 @@ async fn collect_observability(gateway: &GatewayConfig) -> Vec { return checks; } }; - if let Err(error) = register_adaptive_component() { - checks.push(Check { - name: "Adaptive plugin", - status: Status::Fail, - details: format!("registration failed: {error}"), - }); - return checks; - } - if let Err(error) = register_pii_redaction_component() { - checks.push(Check { - name: "PII redaction plugin", + let component_errors = register_and_validate_plugin_components(&plugin_config); + if !component_errors.is_empty() { + checks.extend(component_errors.into_iter().map(|error| Check { + name: error.check_name(), status: Status::Fail, - details: format!("registration failed: {error}"), - }); - return checks; - } - #[cfg(feature = "switchyard")] - if let Err(error) = register_switchyard_component() { - checks.push(Check { - name: "Switchyard plugin", - status: Status::Fail, - details: format!("registration failed: {error}"), - }); - return checks; - } - #[cfg(feature = "switchyard")] - if let Err(error) = validate_switchyard_atof_configuration(&plugin_config) { - checks.push(Check { - name: "Switchyard ATOF", - status: Status::Fail, - details: error, - }); + details: error.diagnostic_details(), + })); return checks; } let report = validate_plugin_config(&plugin_config); @@ -1283,72 +1065,6 @@ async fn probe_atof_websocket( } } -fn check_directory(name: &'static str, path: &Path) -> Check { - match check_dir_writable(path) { - Ok(()) => Check { - name, - status: Status::Pass, - details: format!("{} (appears writable)", path.display()), - }, - Err(err) if err.kind() == std::io::ErrorKind::NotFound => Check { - name, - status: Status::Warn, - details: format!("{}: not present; runtime will create it", path.display()), - }, - Err(err) => Check { - name, - status: Status::Fail, - details: format!("{}: {err}", path.display()), - }, - } -} - -fn check_dir_writable(dir: &Path) -> Result<(), std::io::Error> { - let metadata = std::fs::metadata(dir)?; - if !metadata.is_dir() { - return Err(std::io::Error::new( - std::io::ErrorKind::InvalidInput, - "path is not a directory", - )); - } - if metadata.permissions().readonly() { - return Err(std::io::Error::new( - std::io::ErrorKind::PermissionDenied, - "directory is read-only", - )); - } - Ok(()) -} - -async fn probe_http_named(name: &'static str, url: &str) -> Check { - let client = match reqwest::Client::builder().timeout(NETWORK_TIMEOUT).build() { - Ok(c) => c, - Err(err) => { - return Check { - name, - status: Status::Fail, - details: format!("could not build HTTP client: {err}"), - }; - } - }; - match client.get(url).send().await { - Ok(resp) => Check { - name, - status: if resp.status().is_success() || resp.status().is_redirection() { - Status::Pass - } else { - Status::Warn - }, - details: format!("{} (HTTP {})", url, resp.status().as_u16()), - }, - Err(err) => Check { - name, - status: Status::Fail, - details: format!("{url}: {err}"), - }, - } -} - fn collect_completions(home: Option<&std::path::Path>) -> Vec { let mut checks = Vec::new(); let shell = std::env::var("SHELL").ok().and_then(|s| { @@ -1413,303 +1129,6 @@ fn home_dir() -> Option { } /// Aggregate exit code: 1 if any check is Fail, 0 otherwise. Warnings do not fail. -pub(crate) fn exit_code(report: &DoctorReport) -> u8 { - let any_fail = report - .observability - .iter() - .chain(report.completions.iter()) - .any(|c| matches!(c.status, Status::Fail)) - || report - .agents - .iter() - .any(|agent| matches!(agent.status, Status::Fail)) - || report.host_plugins.iter().any(|plugin| !plugin.ok()) - || matches!(report.configuration.workspace.status, Status::Fail) - || matches!(report.configuration.global.status, Status::Fail) - || matches!(report.configuration.system.status, Status::Fail) - || matches!(report.configuration.plugin_resolution.status, Status::Fail) - || matches!(report.configuration.resolution.status, Status::Fail); - u8::from(any_fail) -} - -// Returns true if any check in the report carries a `Warn` status. Used by the human footer to -// distinguish a fully-green report from one where everything passed but some checks issued -// warnings — both exit 0, but the wording shouldn't. -fn report_has_warn(report: &DoctorReport) -> bool { - report - .observability - .iter() - .chain(report.completions.iter()) - .any(|c| matches!(c.status, Status::Warn)) - || report - .agents - .iter() - .any(|agent| matches!(agent.status, Status::Warn)) - || report.host_plugins.iter().any(|plugin| !plugin.ok()) - || matches!(report.configuration.workspace.status, Status::Warn) - || matches!(report.configuration.global.status, Status::Warn) - || matches!(report.configuration.system.status, Status::Warn) - || matches!(report.configuration.plugin_resolution.status, Status::Warn) - || matches!(report.configuration.resolution.status, Status::Warn) -} - -/// Renders the doctor report in the fixed human-readable layout the design doc shows. Sections -/// stay in the same order across runs so users can diff across machines. The banner header lives -/// in `crate::banner::print_doctor_header` (called from `run_doctor` before this renders) so the -/// pure formatter stays banner-free for tests. -pub(crate) fn format_human(report: &DoctorReport) -> String { - let mut out = String::new(); - format_human_header(&mut out, report); - format_human_environment(&mut out, report); - format_human_configuration(&mut out, report); - format_human_plugin_configuration(&mut out, report); - format_human_agents(&mut out, report); - format_human_host_plugins(&mut out, report); - format_human_checks(&mut out, "Observability", &report.observability); - format_human_completion_checks(&mut out, &report.completions); - format_human_conclusion(&mut out, report); - out -} - -fn format_human_header(out: &mut String, report: &DoctorReport) { - out.push_str(&format!("\n NeMo Relay {}\n", report.binary_version)); - out.push_str(" ─────────────────────────────────────────────\n"); - if let Some(agent) = &report.target_agent { - out.push_str(&format!(" Target agent {agent}\n\n")); - } -} - -fn format_human_environment(out: &mut String, report: &DoctorReport) { - out.push_str(" Environment\n"); - out.push_str(&format!( - " OS {}\n", - report.environment.os.trim() - )); - out.push_str(&format!(" Arch {}\n", report.environment.arch)); - if let Some(shell) = &report.environment.shell { - out.push_str(&format!(" Shell {shell}\n")); - } - out.push('\n'); -} - -fn format_human_configuration(out: &mut String, report: &DoctorReport) { - out.push_str(" Configuration\n"); - out.push_str(&format!( - " Workspace {}\n", - format_layer(&report.configuration.workspace) - )); - out.push_str(&format!( - " Global {}\n", - format_layer(&report.configuration.global) - )); - out.push_str(&format!( - " System {}\n", - format_layer(&report.configuration.system) - )); - if !matches!(report.configuration.resolution.status, Status::Pass) { - out.push_str(&format!( - " Resolution {} {}\n", - format_status(report.configuration.resolution.status), - report.configuration.resolution.details - )); - } - if !report.configuration.configured_agents.is_empty() { - out.push_str(&format!( - " Agents {}\n", - report.configuration.configured_agents.join(", ") - )); - } - out.push('\n'); -} - -fn format_human_plugin_configuration(out: &mut String, report: &DoctorReport) { - out.push_str(" Plugin configuration\n"); - for plugin in &report.configuration.dynamic_plugins { - let config_suffix = if matches!( - plugin.host_config_status, - DynamicPluginHostConfigStatus::Present - ) { - "; host config" - } else { - "" - }; - out.push_str(&format!( - " Dynamic {} ({}){}\n", - plugin.plugin_id, plugin.manifest_ref, config_suffix - )); - } - if !report.configuration.plugin_configs.is_empty() { - for (index, layer) in report.configuration.plugin_configs.iter().enumerate() { - let label = if index == 0 { "Plugin files" } else { "" }; - out.push_str(&format!(" {label:<13}{}\n", format_layer(layer))); - } - } - out.push_str(&format!( - " Plugins {} {}\n", - format_status(report.configuration.plugin_resolution.status), - report.configuration.plugin_resolution.details - )); - for plugin in &report.configuration.dynamic_plugins { - for check in [ - dynamic_plugin_reference_check(plugin), - dynamic_plugin_host_config_check(plugin), - ] { - out.push_str(&format!( - " Dynamic {} {}\n", - format_status(check.status), - check.details - )); - } - } - out.push('\n'); -} - -fn format_human_agents(out: &mut String, report: &DoctorReport) { - out.push_str(" Agents detected\n"); - for agent in &report.agents { - let status = format_status(agent.status); - match &agent.path { - Some(path) => { - let version = agent.version.as_deref().unwrap_or("(unknown version)"); - out.push_str(&format!( - " {} {:<8} {}\n command {}\n path {}\n {}\n", - status, - agent.name, - version, - agent.command, - path.display(), - agent.annotation - )); - } - None => { - out.push_str(&format!( - " {} {:<8} not on $PATH\n command {}\n {}\n", - status, agent.name, agent.command, agent.annotation - )); - } - } - } - out.push('\n'); -} - -fn format_human_host_plugins(out: &mut String, report: &DoctorReport) { - out.push_str(" Host plugins\n"); - if report.host_plugins.is_empty() { - out.push_str(" · none installed; run `nemo-relay install ` to enable persistent host plugins\n"); - } else { - for plugin in &report.host_plugins { - out.push_str(&format!( - " {} {}\n", - if plugin.ok() { "✓" } else { "✗" }, - plugin.host - )); - for check in &plugin.checks { - out.push_str(&format!( - " {} {}: {}\n", - if check.ok { "✓" } else { "✗" }, - check.name, - check.details - )); - } - if !plugin.ok() { - out.push_str(&format!(" repair: {}\n", plugin.remediation)); - } - } - } - out.push('\n'); -} - -fn format_human_checks(out: &mut String, title: &str, checks: &[Check]) { - out.push_str(&format!(" {title}\n")); - for check in checks { - out.push_str(&format!(" {:<22} {}\n", check.name, check.details)); - } - out.push('\n'); -} - -fn format_human_completion_checks(out: &mut String, checks: &[Check]) { - out.push_str(" Completions\n"); - for check in checks { - out.push_str(&format!(" {}\n", check.details)); - } - out.push('\n'); -} - -fn format_human_conclusion(out: &mut String, report: &DoctorReport) { - if exit_code(report) == 0 { - if report_has_warn(report) { - out.push_str(" All checks passed, but some issued warnings; see details above.\n"); - } else { - out.push_str(" All checks passed.\n"); - } - } else { - out.push_str(" Some checks FAILED; see details above.\n"); - } -} - -fn format_layer(layer: &ConfigLayer) -> String { - let active = if layer.active { " (loaded)" } else { "" }; - format!("{} {}{}", layer.path.display(), layer.details, active) -} - -fn format_status(status: Status) -> &'static str { - match status { - Status::Pass => "✓", - Status::Warn => "!", - Status::Fail => "✗", - Status::Info => "·", - } -} - -/// Renders the doctor report as machine-readable JSON. Versioned via `schema_version` so -/// downstream consumers (CI dashboards, eval harnesses) can detect schema changes. -pub(crate) fn format_json(report: &DoctorReport) -> Result { - serde_json::to_string_pretty(report) - .map_err(|err| CliError::Config(format!("could not serialize doctor report: {err}"))) -} - -/// Runs `agents` — a thin wrapper over `collect_agents` that emits only the agent list. Shares -/// the same JSON schema as `doctor.agents` for consistency. -pub(crate) async fn agents_report() -> Vec { - let resolved = resolve_server_config(&ServerArgs::default()).unwrap_or_default(); - collect_agents(None, &resolved).await -} - -/// Renders the agents listing in human form. -pub(crate) fn format_agents_human(agents: &[AgentInfo]) -> String { - let mut out = String::new(); - out.push_str("\n Supported\n"); - for agent in agents { - out.push_str(&format!(" {}\n", agent.name)); - } - out.push('\n'); - out.push_str(" Detected on this machine\n"); - let detected: Vec<&AgentInfo> = agents.iter().filter(|a| a.path.is_some()).collect(); - if detected.is_empty() { - out.push_str(" (none)\n"); - } else { - for agent in detected { - let version = agent.version.as_deref().unwrap_or("(unknown version)"); - let path = agent - .path - .as_ref() - .map(|p| p.display().to_string()) - .unwrap_or_default(); - out.push_str(&format!( - " {} {:<8} {}\n {}\n {}\n", - format_status(agent.status), - agent.name, - version, - path, - agent.annotation - )); - } - } - out.push('\n'); - out -} - -/// Renders the agents listing as JSON. Same shape as `DoctorReport.agents`. pub(crate) fn format_agents_json(agents: &[AgentInfo]) -> Result { serde_json::to_string_pretty(agents) .map_err(|err| CliError::Config(format!("could not serialize agents report: {err}"))) @@ -1739,7 +1158,7 @@ pub(crate) async fn run_doctor( /// Top-level entry point invoked by `nemo-relay agents`. Always exits 0; the data drives caller /// decisions (e.g., CI gating on JSON output). pub(crate) async fn run_agents(json: bool) -> Result { - let agents = agents_report().await; + let agents = agents_report().await?; let output = if json { format_agents_json(&agents)? } else { @@ -1757,5 +1176,5 @@ const _: fn() = || { }; #[cfg(test)] -#[path = "../tests/coverage/doctor_tests.rs"] +#[path = "../../tests/coverage/shared/doctor_tests.rs"] mod tests; diff --git a/crates/cli/src/diagnostics/model.rs b/crates/cli/src/diagnostics/model.rs new file mode 100644 index 000000000..82250d3d4 --- /dev/null +++ b/crates/cli/src/diagnostics/model.rs @@ -0,0 +1,89 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Stable diagnostic report data model. + +use std::path::PathBuf; + +use serde::Serialize; + +use crate::configuration::DynamicPluginHostConfigStatus; + +/// Outcome of one check inside the doctor report. +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub(crate) struct Check { + pub name: &'static str, + pub status: Status, + pub details: String, +} + +#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub(crate) enum Status { + Pass, + Warn, + Fail, + /// The check ran but no relevant state was detected. + Info, +} + +/// Snapshot of the running system rendered by `doctor`. +#[derive(Debug, Clone, Serialize)] +pub(crate) struct DoctorReport { + pub schema_version: u32, + pub binary_version: &'static str, + pub target_agent: Option, + pub environment: EnvironmentInfo, + pub configuration: ConfigurationInfo, + pub agents: Vec, + pub host_plugins: Vec, + pub observability: Vec, + pub completions: Vec, +} + +#[derive(Debug, Clone, Serialize)] +pub(crate) struct EnvironmentInfo { + pub os: String, + pub arch: &'static str, + pub shell: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub(crate) struct ConfigurationInfo { + pub workspace: ConfigLayer, + pub global: ConfigLayer, + pub system: ConfigLayer, + pub plugin_configs: Vec, + pub plugin_resolution: Check, + pub resolution: Check, + pub default_agent: Option, + pub configured_agents: Vec, + pub dynamic_plugins: Vec, +} + +#[derive(Debug, Clone, Serialize)] +pub(crate) struct DynamicPluginReferenceInfo { + pub plugin_id: String, + pub manifest_ref: String, + pub source: PathBuf, + pub host_config_status: DynamicPluginHostConfigStatus, +} + +#[derive(Debug, Clone, Serialize)] +pub(crate) struct ConfigLayer { + pub path: PathBuf, + pub status: Status, + pub active: bool, + pub details: String, +} + +#[derive(Debug, Clone, Serialize)] +pub(crate) struct AgentInfo { + pub name: &'static str, + pub status: Status, + pub configured: bool, + pub command: String, + pub path: Option, + pub version: Option, + pub annotation: String, +} diff --git a/crates/cli/src/diagnostics/probes.rs b/crates/cli/src/diagnostics/probes.rs new file mode 100644 index 000000000..cbfe5df6a --- /dev/null +++ b/crates/cli/src/diagnostics/probes.rs @@ -0,0 +1,74 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Bounded filesystem and network diagnostic probes. + +use super::{Check, NETWORK_TIMEOUT, Status}; +use std::fs::OpenOptions; +use std::path::Path; + +pub(super) fn check_directory(name: &'static str, path: &Path) -> Check { + match check_dir_writable(path) { + Ok(()) => Check { + name, + status: Status::Pass, + details: format!("{} (appears writable)", path.display()), + }, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Check { + name, + status: Status::Warn, + details: format!("{}: not present; runtime will create it", path.display()), + }, + Err(error) => Check { + name, + status: Status::Fail, + details: format!("{}: {error}", path.display()), + }, + } +} + +pub(super) fn check_dir_writable(directory: &Path) -> Result<(), std::io::Error> { + let metadata = std::fs::metadata(directory)?; + if !metadata.is_dir() { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "path is not a directory", + )); + } + let probe = directory.join(format!(".nemo-relay-write-probe-{}", uuid::Uuid::now_v7())); + let file = OpenOptions::new() + .write(true) + .create_new(true) + .open(&probe)?; + drop(file); + std::fs::remove_file(probe) +} + +pub(super) async fn probe_http_named(name: &'static str, url: &str) -> Check { + let client = match reqwest::Client::builder().timeout(NETWORK_TIMEOUT).build() { + Ok(client) => client, + Err(error) => { + return Check { + name, + status: Status::Fail, + details: format!("could not build HTTP client: {error}"), + }; + } + }; + match client.get(url).send().await { + Ok(response) => Check { + name, + status: if response.status().is_success() || response.status().is_redirection() { + Status::Pass + } else { + Status::Warn + }, + details: format!("{} (HTTP {})", url, response.status().as_u16()), + }, + Err(error) => Check { + name, + status: Status::Fail, + details: format!("{url}: {error}"), + }, + } +} diff --git a/crates/cli/src/diagnostics/render.rs b/crates/cli/src/diagnostics/render.rs new file mode 100644 index 000000000..49190b710 --- /dev/null +++ b/crates/cli/src/diagnostics/render.rs @@ -0,0 +1,307 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Doctor and agent-report presentation. + +use super::*; + +pub(crate) fn exit_code(report: &DoctorReport) -> u8 { + let any_fail = report + .observability + .iter() + .chain(report.completions.iter()) + .any(|c| matches!(c.status, Status::Fail)) + || report + .agents + .iter() + .any(|agent| matches!(agent.status, Status::Fail)) + || report.host_plugins.iter().any(|plugin| !plugin.ok()) + || matches!(report.configuration.workspace.status, Status::Fail) + || matches!(report.configuration.global.status, Status::Fail) + || matches!(report.configuration.system.status, Status::Fail) + || matches!(report.configuration.plugin_resolution.status, Status::Fail) + || matches!(report.configuration.resolution.status, Status::Fail); + u8::from(any_fail) +} + +// Returns true if any check in the report carries a `Warn` status. Used by the human footer to +// distinguish a fully-green report from one where everything passed but some checks issued +// warnings — both exit 0, but the wording shouldn't. +pub(super) fn report_has_warn(report: &DoctorReport) -> bool { + report + .observability + .iter() + .chain(report.completions.iter()) + .any(|c| matches!(c.status, Status::Warn)) + || report + .agents + .iter() + .any(|agent| matches!(agent.status, Status::Warn)) + || report.host_plugins.iter().any(|plugin| !plugin.ok()) + || matches!(report.configuration.workspace.status, Status::Warn) + || matches!(report.configuration.global.status, Status::Warn) + || matches!(report.configuration.system.status, Status::Warn) + || matches!(report.configuration.plugin_resolution.status, Status::Warn) + || matches!(report.configuration.resolution.status, Status::Warn) +} + +/// Renders the doctor report in the fixed human-readable layout the design doc shows. Sections +/// stay in the same order across runs so users can diff across machines. The banner header lives +/// in `crate::banner::print_doctor_header` (called from `run_doctor` before this renders) so the +/// pure formatter stays banner-free for tests. +pub(crate) fn format_human(report: &DoctorReport) -> String { + let mut out = String::new(); + format_human_header(&mut out, report); + format_human_environment(&mut out, report); + format_human_configuration(&mut out, report); + format_human_plugin_configuration(&mut out, report); + format_human_agents(&mut out, report); + format_human_host_plugins(&mut out, report); + format_human_checks(&mut out, "Observability", &report.observability); + format_human_completion_checks(&mut out, &report.completions); + format_human_conclusion(&mut out, report); + out +} + +pub(super) fn format_human_header(out: &mut String, report: &DoctorReport) { + out.push_str(&format!("\n NeMo Relay {}\n", report.binary_version)); + out.push_str(" ─────────────────────────────────────────────\n"); + if let Some(agent) = &report.target_agent { + out.push_str(&format!(" Target agent {agent}\n\n")); + } +} + +pub(super) fn format_human_environment(out: &mut String, report: &DoctorReport) { + out.push_str(" Environment\n"); + out.push_str(&format!( + " OS {}\n", + report.environment.os.trim() + )); + out.push_str(&format!(" Arch {}\n", report.environment.arch)); + if let Some(shell) = &report.environment.shell { + out.push_str(&format!(" Shell {shell}\n")); + } + out.push('\n'); +} + +pub(super) fn format_human_configuration(out: &mut String, report: &DoctorReport) { + out.push_str(" Configuration\n"); + out.push_str(&format!( + " Workspace {}\n", + format_layer(&report.configuration.workspace) + )); + out.push_str(&format!( + " Global {}\n", + format_layer(&report.configuration.global) + )); + out.push_str(&format!( + " System {}\n", + format_layer(&report.configuration.system) + )); + if !matches!(report.configuration.resolution.status, Status::Pass) { + out.push_str(&format!( + " Resolution {} {}\n", + format_status(report.configuration.resolution.status), + report.configuration.resolution.details + )); + } + if !report.configuration.configured_agents.is_empty() { + out.push_str(&format!( + " Agents {}\n", + report.configuration.configured_agents.join(", ") + )); + } + out.push('\n'); +} + +pub(super) fn format_human_plugin_configuration(out: &mut String, report: &DoctorReport) { + out.push_str(" Plugin configuration\n"); + for plugin in &report.configuration.dynamic_plugins { + let config_suffix = if matches!( + plugin.host_config_status, + DynamicPluginHostConfigStatus::Present + ) { + "; host config" + } else { + "" + }; + out.push_str(&format!( + " Dynamic {} ({}){}\n", + plugin.plugin_id, plugin.manifest_ref, config_suffix + )); + } + if !report.configuration.plugin_configs.is_empty() { + for (index, layer) in report.configuration.plugin_configs.iter().enumerate() { + let label = if index == 0 { "Plugin files" } else { "" }; + out.push_str(&format!(" {label:<13}{}\n", format_layer(layer))); + } + } + out.push_str(&format!( + " Plugins {} {}\n", + format_status(report.configuration.plugin_resolution.status), + report.configuration.plugin_resolution.details + )); + for plugin in &report.configuration.dynamic_plugins { + for check in [ + dynamic_plugin_reference_check(plugin), + dynamic_plugin_host_config_check(plugin), + ] { + out.push_str(&format!( + " Dynamic {} {}\n", + format_status(check.status), + check.details + )); + } + } + out.push('\n'); +} + +pub(super) fn format_human_agents(out: &mut String, report: &DoctorReport) { + out.push_str(" Agents detected\n"); + for agent in &report.agents { + let status = format_status(agent.status); + match &agent.path { + Some(path) => { + let version = agent.version.as_deref().unwrap_or("(unknown version)"); + out.push_str(&format!( + " {} {:<8} {}\n command {}\n path {}\n {}\n", + status, + agent.name, + version, + agent.command, + path.display(), + agent.annotation + )); + } + None => { + out.push_str(&format!( + " {} {:<8} not on $PATH\n command {}\n {}\n", + status, agent.name, agent.command, agent.annotation + )); + } + } + } + out.push('\n'); +} + +pub(super) fn format_human_host_plugins(out: &mut String, report: &DoctorReport) { + out.push_str(" Persistent integrations\n"); + if report.host_plugins.is_empty() { + out.push_str(" · none installed; run `nemo-relay install ` to enable one\n"); + } else { + for plugin in &report.host_plugins { + out.push_str(&format!( + " {} {}\n", + if plugin.ok() { "✓" } else { "✗" }, + plugin.host + )); + for check in &plugin.checks { + out.push_str(&format!( + " {} {}: {}\n", + if check.ok { "✓" } else { "✗" }, + check.name, + check.details + )); + } + if !plugin.ok() { + out.push_str(&format!(" repair: {}\n", plugin.remediation)); + } + } + } + out.push('\n'); +} + +pub(super) fn format_human_checks(out: &mut String, title: &str, checks: &[Check]) { + out.push_str(&format!(" {title}\n")); + for check in checks { + out.push_str(&format!( + " {} {:<22} {}\n", + format_status(check.status), + check.name, + check.details + )); + } + out.push('\n'); +} + +pub(super) fn format_human_completion_checks(out: &mut String, checks: &[Check]) { + out.push_str(" Completions\n"); + for check in checks { + out.push_str(&format!(" {}\n", check.details)); + } + out.push('\n'); +} + +pub(super) fn format_human_conclusion(out: &mut String, report: &DoctorReport) { + if exit_code(report) == 0 { + if report_has_warn(report) { + out.push_str(" All checks passed, but some issued warnings; see details above.\n"); + } else { + out.push_str(" All checks passed.\n"); + } + } else { + out.push_str(" Some checks FAILED; see details above.\n"); + } +} + +pub(super) fn format_layer(layer: &ConfigLayer) -> String { + let active = if layer.active { " (loaded)" } else { "" }; + format!("{} {}{}", layer.path.display(), layer.details, active) +} + +pub(super) fn format_status(status: Status) -> &'static str { + match status { + Status::Pass => "✓", + Status::Warn => "!", + Status::Fail => "✗", + Status::Info => "·", + } +} + +/// Renders the doctor report as machine-readable JSON. Versioned via `schema_version` so +/// downstream consumers (CI dashboards, eval harnesses) can detect schema changes. +pub(crate) fn format_json(report: &DoctorReport) -> Result { + serde_json::to_string_pretty(report) + .map_err(|err| CliError::Config(format!("could not serialize doctor report: {err}"))) +} + +/// Runs `agents` — a thin wrapper over `collect_agents` that emits only the agent list. Shares +/// the same JSON schema as `doctor.agents` for consistency. +pub(crate) async fn agents_report() -> Result, CliError> { + let resolved = resolve_server_config(&GatewayOverrides::default())?; + Ok(collect_agents(None, &resolved).await) +} + +/// Renders the agents listing in human form. +pub(crate) fn format_agents_human(agents: &[AgentInfo]) -> String { + let mut out = String::new(); + out.push_str("\n Supported\n"); + for agent in agents { + out.push_str(&format!(" {}\n", agent.name)); + } + out.push('\n'); + out.push_str(" Detected on this machine\n"); + let detected: Vec<&AgentInfo> = agents.iter().filter(|a| a.path.is_some()).collect(); + if detected.is_empty() { + out.push_str(" (none)\n"); + } else { + for agent in detected { + let version = agent.version.as_deref().unwrap_or("(unknown version)"); + let path = agent + .path + .as_ref() + .map(|p| p.display().to_string()) + .unwrap_or_default(); + out.push_str(&format!( + " {} {:<8} {}\n {}\n {}\n", + format_status(agent.status), + agent.name, + version, + path, + agent.annotation + )); + } + } + out.push('\n'); + out +} diff --git a/crates/cli/src/json_path.rs b/crates/cli/src/events/json_path.rs similarity index 100% rename from crates/cli/src/json_path.rs rename to crates/cli/src/events/json_path.rs diff --git a/crates/cli/src/model.rs b/crates/cli/src/events/mod.rs similarity index 97% rename from crates/cli/src/model.rs rename to crates/cli/src/events/mod.rs index 4d7e8a792..e14a2374e 100644 --- a/crates/cli/src/model.rs +++ b/crates/cli/src/events/mod.rs @@ -3,6 +3,8 @@ use serde_json::Value; +pub(crate) mod json_path; + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub(crate) enum AgentKind { Codex, @@ -30,7 +32,7 @@ pub(crate) enum NormalizedEvent { AgentEnded(SessionEvent), /// Conversation-turn boundary that the gateway uses to snapshot ATIF without closing the /// agent scope. Emitted alongside `LlmHint` for `Stop` hooks (Claude/Codex). - /// Required for codex 0.129 transparent runs because codex has no `SessionEnd`-equivalent + /// Required for Codex transparent runs because Codex has no reliable `SessionEnd`-equivalent /// event — the last `Stop` of the session leaves an up-to-date ATIF on disk. Multi-turn /// sessions write progressively complete trajectories; the underlying `AtifExporter::export()` /// is non-destructive so each snapshot is a cumulative superset of prior writes. diff --git a/crates/cli/src/filesystem/atomic.rs b/crates/cli/src/filesystem/atomic.rs new file mode 100644 index 000000000..89c9abfca --- /dev/null +++ b/crates/cli/src/filesystem/atomic.rs @@ -0,0 +1,540 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Atomic replacement and private-file permission handling. + +use std::fs::{self, File, OpenOptions, Permissions}; +use std::io::{self, Write}; +use std::path::Path; +#[cfg(test)] +use std::path::PathBuf; + +/// Atomically replace `path` with `bytes`, creating its parent directory when needed. +pub(crate) fn atomic_write(path: &Path, bytes: &[u8]) -> Result<(), String> { + let permissions = fs::metadata(path) + .ok() + .filter(|metadata| metadata.is_file()) + .map(|metadata| metadata.permissions()); + atomic_write_with_permissions(path, bytes, permissions.as_ref()) +} + +/// Atomically replace a secret-bearing file with owner-only access. +/// +/// The restriction is applied to the temporary file at creation, before its name is visible to +/// another process. This avoids both a permissive umask on Unix and inherited broad directory +/// access-control entries on Windows. +pub(crate) fn atomic_write_private(path: &Path, bytes: &[u8]) -> Result<(), String> { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + + atomic_write_impl( + path, + bytes, + Some(&Permissions::from_mode(0o600)), + AtomicWritePrivacy::Private, + None, + ) + } + #[cfg(windows)] + { + atomic_write_impl(path, bytes, None, AtomicWritePrivacy::Private, None) + } + #[cfg(not(any(unix, windows)))] + { + atomic_write_impl(path, bytes, None, AtomicWritePrivacy::Standard, None) + } +} + +/// Atomically replace `path` while applying `permissions` before the new bytes become visible. +pub(crate) fn atomic_write_with_permissions( + path: &Path, + bytes: &[u8], + permissions: Option<&Permissions>, +) -> Result<(), String> { + atomic_write_impl(path, bytes, permissions, AtomicWritePrivacy::Standard, None) +} + +/// Atomically restore bytes with an exact Windows discretionary access-control descriptor. +#[cfg(windows)] +pub(crate) fn atomic_write_with_windows_dacl( + path: &Path, + bytes: &[u8], + dacl: &[u8], +) -> Result<(), String> { + atomic_write_impl(path, bytes, None, AtomicWritePrivacy::Standard, Some(dacl)) +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum AtomicWritePrivacy { + Standard, + Private, +} + +fn atomic_write_impl( + path: &Path, + bytes: &[u8], + permissions: Option<&Permissions>, + privacy: AtomicWritePrivacy, + windows_dacl: Option<&[u8]>, +) -> Result<(), String> { + #[cfg(test)] + if take_injected_atomic_write_failure(path) { + return Err(format!( + "failed to write {}: injected test failure", + path.display() + )); + } + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .map_err(|error| format!("failed to create {}: {error}", parent.display()))?; + } + let file_name = path + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or("nemo-relay"); + let tmp = path.with_file_name(format!(".{file_name}.{}.tmp", uuid::Uuid::now_v7())); + let result = (|| { + let mut file = open_atomic_temp(&tmp, path, permissions, privacy, windows_dacl) + .map_err(|error| format!("failed to create {}: {error}", tmp.display()))?; + file.write_all(bytes) + .map_err(|error| format!("failed to write {}: {error}", tmp.display()))?; + file.sync_all() + .map_err(|error| format!("failed to sync {}: {error}", tmp.display()))?; + if let Some(permissions) = permissions { + fs::set_permissions(&tmp, permissions.clone()).map_err(|error| { + format!("failed to set permissions on {}: {error}", tmp.display()) + })?; + } + drop(file); + replace_file(&tmp, path) + })(); + if result.is_err() { + let _ = fs::remove_file(&tmp); + } + result +} + +#[cfg(unix)] +fn open_atomic_temp( + tmp: &Path, + _target: &Path, + permissions: Option<&Permissions>, + _privacy: AtomicWritePrivacy, + _windows_dacl: Option<&[u8]>, +) -> io::Result { + use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; + + let mut options = OpenOptions::new(); + options.create_new(true).write(true); + if let Some(permissions) = permissions { + options.mode(permissions.mode() & 0o7777); + } + options.open(tmp) +} + +#[cfg(windows)] +fn open_atomic_temp( + tmp: &Path, + target: &Path, + _permissions: Option<&Permissions>, + privacy: AtomicWritePrivacy, + windows_dacl: Option<&[u8]>, +) -> io::Result { + if let Some(descriptor) = windows_dacl { + return create_windows_file(tmp, descriptor.as_ptr().cast_mut().cast()); + } + if privacy == AtomicWritePrivacy::Private { + return create_private_windows_file(tmp); + } + if target.exists() { + let mut descriptor = read_windows_dacl(target)?; + return create_windows_file(tmp, descriptor.as_mut_ptr().cast()); + } + OpenOptions::new().create_new(true).write(true).open(tmp) +} + +#[cfg(not(any(unix, windows)))] +fn open_atomic_temp( + tmp: &Path, + _target: &Path, + _permissions: Option<&Permissions>, + _privacy: AtomicWritePrivacy, + _windows_dacl: Option<&[u8]>, +) -> io::Result { + OpenOptions::new().create_new(true).write(true).open(tmp) +} + +#[cfg(windows)] +fn create_private_windows_file(path: &Path) -> io::Result { + with_private_windows_descriptor(|descriptor| create_windows_file(path, descriptor)) +} + +/// Opens or creates a secret-bearing file without inheriting a broad Windows DACL. +/// +/// The protected owner/System descriptor is applied by `CreateFileW` when the file is created and +/// repaired before an existing file is returned to the caller. The containing directory must be +/// protected separately before this function is called. +#[cfg(windows)] +pub(crate) fn open_private_windows_file(path: &Path) -> io::Result { + use windows_sys::Win32::Foundation::{GENERIC_READ, GENERIC_WRITE}; + use windows_sys::Win32::Storage::FileSystem::{ + FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE, OPEN_ALWAYS, + }; + + let file = with_private_windows_descriptor(|descriptor| { + open_windows_file( + path, + descriptor, + GENERIC_READ | GENERIC_WRITE, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, + OPEN_ALWAYS, + ) + })?; + protect_private_windows_path(path)?; + Ok(file) +} + +/// Applies and verifies the protected owner/System DACL used for secret-bearing Windows paths. +#[cfg(windows)] +pub(crate) fn protect_private_windows_path(path: &Path) -> io::Result<()> { + use windows_sys::Win32::Security::{ + DACL_SECURITY_INFORMATION, PROTECTED_DACL_SECURITY_INFORMATION, SetFileSecurityW, + }; + + if !windows_path_owned_by_current_user(path)? { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + format!("{} is not owned by the current user", path.display()), + )); + } + let path_wide = windows_wide(path.as_os_str()); + with_private_windows_descriptor(|descriptor| { + // SAFETY: The path and descriptor remain valid for the duration of the call. + if unsafe { + SetFileSecurityW( + path_wide.as_ptr(), + DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION, + descriptor, + ) + } == 0 + { + return Err(io::Error::last_os_error()); + } + Ok(()) + })?; + if !windows_path_is_private(path)? { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + format!( + "failed to verify protected owner/System access on {}", + path.display() + ), + )); + } + Ok(()) +} + +#[cfg(windows)] +fn with_private_windows_descriptor( + operation: impl FnOnce(windows_sys::Win32::Security::PSECURITY_DESCRIPTOR) -> io::Result, +) -> io::Result { + use windows_sys::Win32::Foundation::LocalFree; + use windows_sys::Win32::Security::Authorization::{ + ConvertStringSecurityDescriptorToSecurityDescriptorW, SDDL_REVISION_1, + }; + use windows_sys::Win32::Security::PSECURITY_DESCRIPTOR; + + let descriptor_sddl = windows_wide("D:P(A;;FA;;;OW)(A;;FA;;;SY)"); + let mut descriptor: PSECURITY_DESCRIPTOR = std::ptr::null_mut(); + // SAFETY: The SDDL string is NUL-terminated and `descriptor` points to writable storage. The + // returned allocation is released with LocalFree below. + if unsafe { + ConvertStringSecurityDescriptorToSecurityDescriptorW( + descriptor_sddl.as_ptr(), + SDDL_REVISION_1, + &mut descriptor, + std::ptr::null_mut(), + ) + } == 0 + { + return Err(io::Error::last_os_error()); + } + let result = operation(descriptor); + // SAFETY: `descriptor` was allocated by ConvertStringSecurityDescriptor... and has not been + // freed or transferred. + unsafe { LocalFree(descriptor.cast()) }; + result +} + +#[cfg(windows)] +fn windows_path_owned_by_current_user(path: &Path) -> io::Result { + use windows_sys::Win32::Foundation::{CloseHandle, HANDLE}; + use windows_sys::Win32::Security::{ + EqualSid, GetSecurityDescriptorOwner, GetTokenInformation, OWNER_SECURITY_INFORMATION, + PSID, TOKEN_QUERY, TOKEN_USER, TokenUser, + }; + use windows_sys::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken}; + + let mut descriptor = read_windows_security_descriptor(path, OWNER_SECURITY_INFORMATION)?; + let mut owner: PSID = std::ptr::null_mut(); + let mut defaulted = 0; + // SAFETY: The self-relative descriptor buffer is valid and both outputs point to writable + // storage for the duration of the call. + if unsafe { + GetSecurityDescriptorOwner(descriptor.as_mut_ptr().cast(), &mut owner, &mut defaulted) + } == 0 + || owner.is_null() + { + return Err(io::Error::last_os_error()); + } + + let mut token: HANDLE = std::ptr::null_mut(); + // SAFETY: GetCurrentProcess returns a valid pseudo-handle and `token` is writable. + if unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) } == 0 { + return Err(io::Error::last_os_error()); + } + let result = (|| { + let mut required = 0; + // SAFETY: This sizing call intentionally supplies a null output buffer. + unsafe { GetTokenInformation(token, TokenUser, std::ptr::null_mut(), 0, &mut required) }; + if required == 0 { + return Err(io::Error::last_os_error()); + } + let word = std::mem::size_of::(); + let mut buffer = vec![0_usize; (required as usize).div_ceil(word)]; + // SAFETY: The aligned buffer has at least `required` writable bytes. + if unsafe { + GetTokenInformation( + token, + TokenUser, + buffer.as_mut_ptr().cast(), + required, + &mut required, + ) + } == 0 + { + return Err(io::Error::last_os_error()); + } + // SAFETY: GetTokenInformation initialized a TOKEN_USER at the aligned buffer address. + let user = unsafe { &*buffer.as_ptr().cast::() }; + // SAFETY: Both SID pointers remain valid while their backing buffers are alive. + Ok(unsafe { EqualSid(owner, user.User.Sid) != 0 }) + })(); + // SAFETY: `token` is an owned handle returned by OpenProcessToken. + unsafe { CloseHandle(token) }; + result +} + +#[cfg(windows)] +pub(crate) fn windows_path_is_private(path: &Path) -> io::Result { + use windows_sys::Win32::Security::{DACL_SECURITY_INFORMATION, OWNER_SECURITY_INFORMATION}; + + if !windows_path_owned_by_current_user(path)? { + return Ok(false); + } + let mut actual = read_windows_security_descriptor( + path, + OWNER_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION, + )?; + let actual = windows_dacl_sddl(actual.as_mut_ptr().cast())?; + with_private_windows_descriptor(|expected| Ok(actual == windows_dacl_sddl(expected)?)) +} + +#[cfg(windows)] +fn windows_dacl_sddl( + descriptor: windows_sys::Win32::Security::PSECURITY_DESCRIPTOR, +) -> io::Result { + use windows_sys::Win32::Foundation::LocalFree; + use windows_sys::Win32::Security::Authorization::{ + ConvertSecurityDescriptorToStringSecurityDescriptorW, SDDL_REVISION_1, + }; + use windows_sys::Win32::Security::DACL_SECURITY_INFORMATION; + + let mut rendered = std::ptr::null_mut(); + let mut rendered_len = 0; + // SAFETY: The descriptor is valid and both output pointers reference writable storage. + if unsafe { + ConvertSecurityDescriptorToStringSecurityDescriptorW( + descriptor, + SDDL_REVISION_1, + DACL_SECURITY_INFORMATION, + &mut rendered, + &mut rendered_len, + ) + } == 0 + { + return Err(io::Error::last_os_error()); + } + // SAFETY: The API returned `rendered_len` initialized UTF-16 code units. + let value = String::from_utf16_lossy(unsafe { + std::slice::from_raw_parts(rendered, rendered_len as usize) + }) + .trim_end_matches('\0') + .to_string(); + // SAFETY: `rendered` was allocated by ConvertSecurityDescriptor... above. + unsafe { LocalFree(rendered.cast()) }; + Ok(value) +} + +#[cfg(windows)] +pub(crate) fn read_windows_dacl(path: &Path) -> io::Result> { + use windows_sys::Win32::Security::DACL_SECURITY_INFORMATION; + + read_windows_security_descriptor(path, DACL_SECURITY_INFORMATION) +} + +#[cfg(windows)] +fn read_windows_security_descriptor( + path: &Path, + information: windows_sys::Win32::Security::OBJECT_SECURITY_INFORMATION, +) -> io::Result> { + use windows_sys::Win32::Security::GetFileSecurityW; + + let path = windows_wide(path.as_os_str()); + let mut required = 0; + // SAFETY: This sizing call intentionally supplies a null output buffer and valid length + // pointer, as required by GetFileSecurityW. + unsafe { + GetFileSecurityW( + path.as_ptr(), + information, + std::ptr::null_mut(), + 0, + &mut required, + ) + }; + if required == 0 { + return Err(io::Error::last_os_error()); + } + let mut descriptor = vec![0_u8; required as usize]; + // SAFETY: The path is NUL-terminated and the allocated output buffer is `required` bytes. + if unsafe { + GetFileSecurityW( + path.as_ptr(), + information, + descriptor.as_mut_ptr().cast(), + required, + &mut required, + ) + } == 0 + { + return Err(io::Error::last_os_error()); + } + Ok(descriptor) +} + +#[cfg(windows)] +fn create_windows_file( + path: &Path, + descriptor: windows_sys::Win32::Security::PSECURITY_DESCRIPTOR, +) -> io::Result { + use windows_sys::Win32::Foundation::GENERIC_WRITE; + use windows_sys::Win32::Storage::FileSystem::CREATE_NEW; + + open_windows_file(path, descriptor, GENERIC_WRITE, 0, CREATE_NEW) +} + +#[cfg(windows)] +fn open_windows_file( + path: &Path, + descriptor: windows_sys::Win32::Security::PSECURITY_DESCRIPTOR, + desired_access: u32, + share_mode: u32, + creation_disposition: u32, +) -> io::Result { + use std::os::windows::io::FromRawHandle; + use windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE; + use windows_sys::Win32::Security::SECURITY_ATTRIBUTES; + use windows_sys::Win32::Storage::FileSystem::{CreateFileW, FILE_ATTRIBUTE_NORMAL}; + + let path = windows_wide(path.as_os_str()); + let attributes = SECURITY_ATTRIBUTES { + nLength: std::mem::size_of::() as u32, + lpSecurityDescriptor: descriptor, + bInheritHandle: 0, + }; + // SAFETY: The path and security descriptor remain valid for the call, and a successful owned + // handle is transferred to File. + let handle = unsafe { + CreateFileW( + path.as_ptr(), + desired_access, + share_mode, + &attributes, + creation_disposition, + FILE_ATTRIBUTE_NORMAL, + std::ptr::null_mut(), + ) + }; + if handle == INVALID_HANDLE_VALUE { + return Err(io::Error::last_os_error()); + } + // SAFETY: `handle` is a newly created, valid, owned file handle. + Ok(unsafe { File::from_raw_handle(handle) }) +} + +#[cfg(windows)] +pub(crate) fn windows_wide(value: impl AsRef) -> Vec { + use std::os::windows::ffi::OsStrExt; + + value.as_ref().encode_wide().chain(Some(0)).collect() +} + +#[cfg(test)] +pub(crate) fn fail_next_atomic_write(path: &Path) { + injected_atomic_write_failures() + .lock() + .unwrap_or_else(|error| error.into_inner()) + .insert(path.to_path_buf()); +} + +#[cfg(test)] +fn take_injected_atomic_write_failure(path: &Path) -> bool { + injected_atomic_write_failures() + .lock() + .unwrap_or_else(|error| error.into_inner()) + .remove(path) +} + +#[cfg(test)] +fn injected_atomic_write_failures() -> &'static std::sync::Mutex> +{ + use std::collections::HashSet; + use std::sync::{Mutex, OnceLock}; + + static FAILURES: OnceLock>> = OnceLock::new(); + FAILURES.get_or_init(Default::default) +} + +#[cfg(not(windows))] +fn replace_file(tmp: &Path, path: &Path) -> Result<(), String> { + fs::rename(tmp, path).map_err(|error| format!("failed to replace {}: {error}", path.display())) +} + +#[cfg(windows)] +fn replace_file(tmp: &Path, path: &Path) -> Result<(), String> { + use windows_sys::Win32::Storage::FileSystem::{ + MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH, MoveFileExW, + }; + + let tmp = windows_wide(tmp.as_os_str()); + let path_wide = windows_wide(path.as_os_str()); + // SAFETY: Both paths are NUL-terminated and remain valid for the call. The files share a + // directory, so Windows performs one replace-existing rename without a missing-target window. + if unsafe { + MoveFileExW( + tmp.as_ptr(), + path_wide.as_ptr(), + MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH, + ) + } == 0 + { + return Err(format!( + "failed to replace {}: {}", + path.display(), + io::Error::last_os_error() + )); + } + Ok(()) +} diff --git a/crates/cli/src/filesystem/bounded.rs b/crates/cli/src/filesystem/bounded.rs new file mode 100644 index 000000000..6be0ae646 --- /dev/null +++ b/crates/cli/src/filesystem/bounded.rs @@ -0,0 +1,87 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use std::fs::OpenOptions; +use std::io::Read; +use std::path::Path; + +pub(crate) const MAX_BOUNDED_FILE_BYTES: u64 = 512 * 1024 * 1024; + +pub(crate) fn read_bounded_regular_file(path: &Path, description: &str) -> Result, String> { + let mut bytes = Vec::new(); + stream_bounded_regular_file(path, description, |chunk| bytes.extend_from_slice(chunk))?; + Ok(bytes) +} + +pub(crate) fn stream_bounded_regular_file( + path: &Path, + description: &str, + mut consume: impl FnMut(&[u8]), +) -> Result<(), String> { + const BUFFER_BYTES: usize = 64 * 1024; + let metadata = std::fs::symlink_metadata(path).map_err(|error| { + format!( + "failed to inspect {description} {}: {error}", + path.display() + ) + })?; + if !metadata.file_type().is_file() { + return Err(format!( + "{description} {} must be a regular file", + path.display() + )); + } + if metadata.len() > MAX_BOUNDED_FILE_BYTES { + return Err(format!( + "{description} {} exceeds the {MAX_BOUNDED_FILE_BYTES}-byte limit", + path.display() + )); + } + let mut options = OpenOptions::new(); + options.read(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK); + } + let mut file = options + .open(path) + .map_err(|error| format!("failed to read {description} {}: {error}", path.display()))?; + let opened_metadata = file.metadata().map_err(|error| { + format!( + "failed to inspect {description} {}: {error}", + path.display() + ) + })?; + if !opened_metadata.file_type().is_file() { + return Err(format!( + "{description} {} must be a regular file", + path.display() + )); + } + if opened_metadata.len() > MAX_BOUNDED_FILE_BYTES { + return Err(format!( + "{description} {} exceeds the {MAX_BOUNDED_FILE_BYTES}-byte limit", + path.display() + )); + } + let mut buffer = [0_u8; BUFFER_BYTES]; + let mut total = 0_u64; + loop { + let read = file + .read(&mut buffer) + .map_err(|error| format!("failed to read {description} {}: {error}", path.display()))?; + if read == 0 { + break; + } + total = total.saturating_add(read as u64); + if total > MAX_BOUNDED_FILE_BYTES { + return Err(format!( + "{description} {} exceeds the {MAX_BOUNDED_FILE_BYTES}-byte limit", + path.display() + )); + } + consume(&buffer[..read]); + } + Ok(()) +} diff --git a/crates/cli/src/filesystem/locks.rs b/crates/cli/src/filesystem/locks.rs new file mode 100644 index 000000000..21c9eabf3 --- /dev/null +++ b/crates/cli/src/filesystem/locks.rs @@ -0,0 +1,53 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Nonblocking advisory file-lock primitives. + +use std::fs::File; +use std::io; + +use fs2::FileExt; + +/// Result of one nonblocking advisory-file-lock attempt. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum LockAttempt { + Acquired, + Contended, +} + +/// Attempt an exclusive advisory lock without waiting. +pub(crate) fn try_lock_exclusive(file: &File) -> io::Result { + normalize_lock_attempt(FileExt::try_lock_exclusive(file)) +} + +/// Attempt a shared advisory lock without waiting. +pub(crate) fn try_lock_shared(file: &File) -> io::Result { + normalize_lock_attempt(FileExt::try_lock_shared(file)) +} + +/// Release an advisory lock acquired through the helpers above. +pub(crate) fn unlock_file(file: &File) -> io::Result<()> { + FileExt::unlock(file) +} + +pub(crate) fn normalize_lock_attempt(result: io::Result<()>) -> io::Result { + match result { + Ok(()) => Ok(LockAttempt::Acquired), + Err(error) if lock_is_contended(&error) => Ok(LockAttempt::Contended), + Err(error) => Err(error), + } +} + +fn lock_is_contended(error: &io::Error) -> bool { + if error.kind() == io::ErrorKind::WouldBlock { + return true; + } + #[cfg(windows)] + { + error.raw_os_error() == Some(windows_sys::Win32::Foundation::ERROR_LOCK_VIOLATION as i32) + } + #[cfg(not(windows))] + { + false + } +} diff --git a/crates/cli/src/filesystem/mod.rs b/crates/cli/src/filesystem/mod.rs new file mode 100644 index 000000000..808c1796d --- /dev/null +++ b/crates/cli/src/filesystem/mod.rs @@ -0,0 +1,31 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Platform-aware filesystem primitives shared by CLI subsystems. + +mod atomic; +pub(crate) mod bounded; +mod locks; +mod snapshots; +pub(crate) mod temp; + +#[cfg(test)] +pub(crate) use atomic::fail_next_atomic_write; +pub(crate) use atomic::{atomic_write, atomic_write_private, atomic_write_with_permissions}; +#[cfg(windows)] +pub(crate) use atomic::{ + atomic_write_with_windows_dacl, open_private_windows_file, protect_private_windows_path, + read_windows_dacl, +}; +#[cfg(all(test, windows))] +pub(crate) use atomic::{windows_path_is_private, windows_wide}; +#[cfg(all(test, windows))] +pub(crate) use locks::normalize_lock_attempt; +pub(crate) use locks::{LockAttempt, try_lock_exclusive, try_lock_shared, unlock_file}; +pub(crate) use snapshots::{ + FileSnapshot, backup, backup_path, remove_backup, restore_file_snapshot, snapshot_optional_file, +}; + +#[cfg(test)] +#[path = "../../tests/coverage/shared/file_io_tests.rs"] +mod tests; diff --git a/crates/cli/src/filesystem/snapshots.rs b/crates/cli/src/filesystem/snapshots.rs new file mode 100644 index 000000000..4ae16056d --- /dev/null +++ b/crates/cli/src/filesystem/snapshots.rs @@ -0,0 +1,114 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Optional-file snapshots and stable backup-file management. + +use std::fs; +use std::path::{Path, PathBuf}; + +use super::atomic_write_with_permissions; +#[cfg(windows)] +use super::{atomic_write_with_windows_dacl, read_windows_dacl}; + +pub(crate) fn backup(path: &Path) -> Result<(), String> { + let backup = backup_path(path); + if backup.exists() { + return Ok(()); + } + if path.exists() { + let bytes = fs::read(path) + .map_err(|error| format!("failed to read {} for backup: {error}", path.display()))?; + #[cfg(windows)] + { + let dacl = read_windows_dacl(path).map_err(|error| { + format!( + "failed to read access control for {}: {error}", + path.display() + ) + })?; + atomic_write_with_windows_dacl(&backup, &bytes, &dacl)?; + } + #[cfg(not(windows))] + { + let permissions = fs::metadata(path) + .map_err(|error| format!("failed to inspect {}: {error}", path.display()))? + .permissions(); + atomic_write_with_permissions(&backup, &bytes, Some(&permissions))?; + } + } + Ok(()) +} + +pub(crate) fn remove_backup(path: &Path) -> Result<(), String> { + let backup = backup_path(path); + match fs::remove_file(&backup) { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(format!("failed to remove {}: {error}", backup.display())), + } +} + +pub(crate) fn backup_path(path: &Path) -> PathBuf { + let mut extension = path + .extension() + .and_then(|value| value.to_str()) + .unwrap_or_default() + .to_string(); + if extension.is_empty() { + extension = "nemo-relay.bak".into(); + } else { + extension.push_str(".nemo-relay.bak"); + } + path.with_extension(extension) +} + +pub(crate) struct FileSnapshot { + path: PathBuf, + bytes: Option>, + permissions: Option, + #[cfg(windows)] + dacl: Option>, +} + +pub(crate) fn snapshot_optional_file(path: &Path) -> Result { + match fs::read(path) { + Ok(bytes) => Ok(FileSnapshot { + path: path.to_path_buf(), + bytes: Some(bytes), + permissions: fs::metadata(path).ok().map(|value| value.permissions()), + #[cfg(windows)] + dacl: Some(read_windows_dacl(path).map_err(|error| { + format!( + "failed to read access control for {}: {error}", + path.display() + ) + })?), + }), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(FileSnapshot { + path: path.to_path_buf(), + bytes: None, + permissions: None, + #[cfg(windows)] + dacl: None, + }), + Err(error) => Err(format!("failed to read {}: {error}", path.display())), + } +} + +pub(crate) fn restore_file_snapshot(snapshot: &FileSnapshot) -> Result<(), String> { + if let Some(bytes) = snapshot.bytes.as_deref() { + #[cfg(windows)] + if let Some(dacl) = snapshot.dacl.as_deref() { + return atomic_write_with_windows_dacl(&snapshot.path, bytes, dacl); + } + return atomic_write_with_permissions(&snapshot.path, bytes, snapshot.permissions.as_ref()); + } + match fs::remove_file(&snapshot.path) { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(format!( + "failed to remove {}: {error}", + snapshot.path.display() + )), + } +} diff --git a/crates/cli/src/filesystem/temp.rs b/crates/cli/src/filesystem/temp.rs new file mode 100644 index 000000000..f0f69ed93 --- /dev/null +++ b/crates/cli/src/filesystem/temp.rs @@ -0,0 +1,39 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use std::path::{Path, PathBuf}; + +use crate::error::CliError; + +pub(crate) fn private_temp_dir(parent: &Path, prefix: &str) -> Result { + let path = parent.join(format!("{prefix}-{}", uuid::Uuid::now_v7())); + #[cfg(unix)] + let builder = { + use std::os::unix::fs::DirBuilderExt; + let mut builder = std::fs::DirBuilder::new(); + builder.mode(0o700); + builder + }; + #[cfg(not(unix))] + let builder = std::fs::DirBuilder::new(); + builder.create(&path)?; + #[cfg(windows)] + if let Err(error) = crate::filesystem::protect_private_windows_path(&path) { + let cleanup = std::fs::remove_dir(&path); + return Err(CliError::Io(match cleanup { + Ok(()) => error, + Err(cleanup_error) => std::io::Error::new( + cleanup_error.kind(), + format!( + "{error}; additionally failed to remove {}: {cleanup_error}", + path.display() + ), + ), + })); + } + Ok(path) +} + +pub(crate) fn private_system_temp_dir(prefix: &str) -> Result { + private_temp_dir(&std::env::temp_dir(), prefix) +} diff --git a/crates/cli/src/gateway/client.rs b/crates/cli/src/gateway/client.rs new file mode 100644 index 000000000..c26a822cb --- /dev/null +++ b/crates/cli/src/gateway/client.rs @@ -0,0 +1,648 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Authenticated health and shutdown transport for loopback sidecars. + +use std::collections::HashMap; +use std::io::{Read, Write}; +use std::net::{Ipv4Addr, SocketAddr, TcpStream, ToSocketAddrs}; +use std::path::PathBuf; +use std::sync::{Arc, Mutex, OnceLock}; +use std::time::Duration; + +use reqwest::Url; +use ring::rand::{SecureRandom, SystemRandom}; +use serde_json::Value; + +use crate::configuration::BootstrapChallengeKey; + +use crate::bootstrap::{BOOTSTRAP_PROTOCOL_VERSION, HEALTHZ_TIMEOUT}; + +static CHALLENGE_KEY_CACHE: OnceLock>>> = + OnceLock::new(); +static TLS_IDENTITY_CACHE: OnceLock< + Mutex>>, +> = OnceLock::new(); + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum RelayHealth { + Compatible, + Incompatible, + Foreign, + Unavailable, +} + +#[derive(Debug)] +pub(crate) struct VerifiedHttpResponse { + pub(crate) status: u16, + pub(crate) body: Vec, +} + +#[derive(Debug)] +pub(crate) struct VerifiedHttpError { + message: String, +} + +impl VerifiedHttpError { + fn before_payload(message: impl Into) -> Self { + Self { + message: message.into(), + } + } + + fn after_payload(message: impl Into) -> Self { + Self { + message: message.into(), + } + } + + pub(crate) fn missing_fingerprint() -> Self { + Self::after_payload("managed Relay gateway is missing its bootstrap fingerprint") + } +} + +impl std::fmt::Display for VerifiedHttpError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(&self.message) + } +} + +/// Authenticates a Relay gateway and sends one HTTP request on that same TCP connection. +/// +/// Keeping the challenge and payload on one established connection closes the port-replacement +/// gap between an authenticated health probe and hook delivery. A foreign listener can receive +/// the challenge, but the payload is not written until Relay proves possession of the per-user +/// bootstrap key on the connection that will receive it. +pub(crate) fn post_verified( + url: &str, + bootstrap_fingerprint: &str, + path: &str, + headers: &[(String, String)], + body: &[u8], + timeout: Duration, + max_response_bytes: usize, +) -> Result { + let (host, port) = parse_loopback_url(url).map_err(VerifiedHttpError::after_payload)?; + let addresses = (host.as_str(), port).to_socket_addrs().map_err(|error| { + VerifiedHttpError::before_payload(format!( + "failed to resolve verified gateway {url}: {error}" + )) + })?; + let mut stream = connect_loopback(addresses, timeout).map_err(|error| { + VerifiedHttpError::before_payload(format!( + "failed to connect to verified gateway {url}: {error}" + )) + })?; + stream.set_read_timeout(Some(timeout)).map_err(|error| { + VerifiedHttpError::before_payload(format!( + "failed to configure verified gateway read timeout: {error}" + )) + })?; + stream.set_write_timeout(Some(timeout)).map_err(|error| { + VerifiedHttpError::before_payload(format!( + "failed to configure verified gateway write timeout: {error}" + )) + })?; + + let key = cached_bootstrap_challenge_key().map_err(|error| { + VerifiedHttpError::before_payload(format!( + "failed to load the Relay bootstrap challenge key: {error}" + )) + })?; + let tls_identity = cached_tls_identity().map_err(|error| { + VerifiedHttpError::before_payload(format!( + "failed to load pinned Relay TLS identity: {error}" + )) + })?; + let mut nonce = [0_u8; 32]; + SystemRandom::new().fill(&mut nonce).map_err(|_| { + VerifiedHttpError::after_payload("failed to generate a Relay bootstrap challenge") + })?; + let nonce = nonce + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::(); + let authority = loopback_authority(&host, port); + let tunnel = format!( + "GET /bootstrap/tunnel HTTP/1.1\r\nHost: {authority}\r\nX-NeMo-Relay-Bootstrap-Fingerprint: {bootstrap_fingerprint}\r\nX-NeMo-Relay-Bootstrap-Nonce: {nonce}\r\nConnection: upgrade\r\nUpgrade: nemo-relay-tls\r\n\r\n" + ); + stream.write_all(tunnel.as_bytes()).map_err(|error| { + VerifiedHttpError::before_payload(format!( + "failed to request the Relay TLS tunnel: {error}" + )) + })?; + let (tunnel_headers, _) = read_http_message(&mut stream, 0).map_err(|error| { + VerifiedHttpError::before_payload(format!( + "failed to read the Relay TLS tunnel response: {error}" + )) + })?; + let proof_valid = http_header(&tunnel_headers, "x-nemo-relay-bootstrap-proof") + .is_some_and(|proof| key.verify(bootstrap_fingerprint, &nonce, proof)); + if http_status(&tunnel_headers) != Some(101) + || !proof_valid + || http_header(&tunnel_headers, "upgrade") != Some("nemo-relay-tls") + { + return Err(VerifiedHttpError::before_payload( + "gateway did not establish an authenticated Relay TLS tunnel", + )); + } + let client_config = tls_identity + .client_config() + .map_err(VerifiedHttpError::before_payload)?; + let server_name = rustls::pki_types::ServerName::try_from("localhost").map_err(|error| { + VerifiedHttpError::before_payload(format!("invalid Relay TLS server name: {error}")) + })?; + let connection = + rustls::ClientConnection::new(client_config, server_name).map_err(|error| { + VerifiedHttpError::before_payload(format!("failed to create Relay TLS client: {error}")) + })?; + let mut stream = rustls::StreamOwned::new(connection, stream); + + let challenge = format!( + "GET /healthz HTTP/1.1\r\nHost: {authority}\r\nX-NeMo-Relay-Bootstrap-Fingerprint: {bootstrap_fingerprint}\r\nX-NeMo-Relay-Bootstrap-Nonce: {nonce}\r\nConnection: keep-alive\r\n\r\n" + ); + stream.write_all(challenge.as_bytes()).map_err(|error| { + VerifiedHttpError::before_payload(format!( + "Relay TLS handshake or health request failed: {error}" + )) + })?; + let (health_headers, health_body) = + read_http_message(&mut stream, 16 * 1024).map_err(|error| { + VerifiedHttpError::before_payload(format!( + "failed to read health response through Relay TLS: {error}" + )) + })?; + let (health, _) = classify_health_response( + &health_headers, + &health_body, + Some((bootstrap_fingerprint, nonce.as_str(), key.as_ref())), + ); + match health { + RelayHealth::Compatible => {} + RelayHealth::Incompatible => { + return Err(VerifiedHttpError::after_payload(format!( + "an incompatible NeMo Relay gateway is listening at {url}" + ))); + } + RelayHealth::Foreign | RelayHealth::Unavailable => { + return Err(VerifiedHttpError::after_payload(format!( + "a foreign process is listening at the shared Relay gateway URL {url}" + ))); + } + } + if http_header(&health_headers, "connection") + .is_some_and(|value| value.eq_ignore_ascii_case("close")) + { + return Err(VerifiedHttpError::before_payload( + "verified Relay gateway closed the authenticated connection before request delivery", + )); + } + + let mut request = format!("POST {path} HTTP/1.1\r\nHost: {authority}\r\n"); + for (name, value) in headers { + request.push_str(name); + request.push_str(": "); + request.push_str(value); + request.push_str("\r\n"); + } + request.push_str(&format!( + "Content-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + body.len() + )); + stream.write_all(request.as_bytes()).map_err(|error| { + VerifiedHttpError::before_payload(format!( + "failed to send verified gateway request headers: {error}" + )) + })?; + stream.write_all(body).map_err(|error| { + VerifiedHttpError::after_payload(format!( + "verified gateway payload delivery became indeterminate: {error}" + )) + })?; + let (response_headers, response_body) = read_http_message(&mut stream, max_response_bytes) + .map_err(|error| { + VerifiedHttpError::after_payload(format!( + "failed to read verified gateway response: {error}" + )) + })?; + let status = http_status(&response_headers).ok_or_else(|| { + VerifiedHttpError::after_payload("verified gateway response had an invalid HTTP status") + })?; + Ok(VerifiedHttpResponse { + status, + body: response_body, + }) +} + +pub(crate) fn healthz(url: &str) -> bool { + probe(url, None) == RelayHealth::Compatible +} + +pub(crate) fn healthz_compatible(url: &str, bootstrap_fingerprint: &str) -> bool { + probe(url, Some(bootstrap_fingerprint)) == RelayHealth::Compatible +} + +pub(crate) fn authenticated_instance_id(url: &str, bootstrap_fingerprint: &str) -> Option { + compatible_instance_id(url, Some(bootstrap_fingerprint)) +} + +pub(crate) fn probe(url: &str, bootstrap_fingerprint: Option<&str>) -> RelayHealth { + probe_with_instance(url, bootstrap_fingerprint).0 +} + +pub(crate) fn compatible_instance_id( + url: &str, + bootstrap_fingerprint: Option<&str>, +) -> Option { + let (health, instance_id) = probe_with_instance(url, bootstrap_fingerprint); + (health == RelayHealth::Compatible) + .then_some(instance_id) + .flatten() +} + +pub(crate) fn probe_with_instance( + url: &str, + bootstrap_fingerprint: Option<&str>, +) -> (RelayHealth, Option) { + let Ok((host, port)) = parse_loopback_url(url) else { + return (RelayHealth::Unavailable, None); + }; + let Ok(addrs) = (host.as_str(), port).to_socket_addrs() else { + return (RelayHealth::Unavailable, None); + }; + let mut stream = None; + for addr in addrs.filter(|addr| addr.ip().is_loopback()) { + match TcpStream::connect_timeout(&addr, HEALTHZ_TIMEOUT) { + Ok(candidate) => { + stream = Some(candidate); + break; + } + Err(_) => continue, + } + } + let Some(mut stream) = stream else { + return (RelayHealth::Unavailable, None); + }; + if stream.set_read_timeout(Some(HEALTHZ_TIMEOUT)).is_err() + || stream.set_write_timeout(Some(HEALTHZ_TIMEOUT)).is_err() + { + return (RelayHealth::Foreign, None); + } + let challenge = bootstrap_fingerprint.map(|fingerprint| { + let key = cached_bootstrap_challenge_key().map_err(|_| ())?; + let mut nonce = [0_u8; 32]; + SystemRandom::new().fill(&mut nonce).map_err(|_| ())?; + let nonce = nonce + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::(); + Ok::<_, ()>((fingerprint, nonce, key)) + }); + let challenge = match challenge.transpose() { + Ok(challenge) => challenge, + Err(()) => return (RelayHealth::Foreign, None), + }; + let fingerprint_headers = challenge + .as_ref() + .map(|(fingerprint, nonce, _)| { + format!( + "X-NeMo-Relay-Bootstrap-Fingerprint: {fingerprint}\r\nX-NeMo-Relay-Bootstrap-Nonce: {nonce}\r\n" + ) + }) + .unwrap_or_default(); + let request = format!( + "GET /healthz HTTP/1.1\r\nHost: {}\r\n{fingerprint_headers}Connection: close\r\n\r\n", + loopback_authority(&host, port) + ); + if stream.write_all(request.as_bytes()).is_err() { + return (RelayHealth::Foreign, None); + } + let Ok((headers, body)) = read_http_message(&mut stream, 16 * 1024) else { + return (RelayHealth::Foreign, None); + }; + classify_health_response( + &headers, + &body, + challenge + .as_ref() + .map(|(fingerprint, nonce, key)| (*fingerprint, nonce.as_str(), key.as_ref())), + ) +} + +pub(crate) fn request_shutdown( + url: &str, + bootstrap_fingerprint: &str, + token: &str, +) -> Result<(), String> { + let (host, port) = parse_loopback_url(url)?; + let addresses = (host.as_str(), port) + .to_socket_addrs() + .map_err(|error| format!("failed to resolve managed sidecar {url}: {error}"))?; + let mut stream = connect_loopback(addresses, HEALTHZ_TIMEOUT) + .map_err(|error| format!("failed to connect to managed sidecar {url}: {error}"))?; + stream + .set_read_timeout(Some(HEALTHZ_TIMEOUT)) + .map_err(|error| format!("failed to configure sidecar shutdown read timeout: {error}"))?; + stream + .set_write_timeout(Some(HEALTHZ_TIMEOUT)) + .map_err(|error| format!("failed to configure sidecar shutdown write timeout: {error}"))?; + let key = cached_bootstrap_challenge_key() + .map_err(|error| format!("failed to load the Relay bootstrap challenge key: {error}"))?; + let mut nonce = [0_u8; 32]; + SystemRandom::new() + .fill(&mut nonce) + .map_err(|_| "failed to generate a Relay bootstrap shutdown challenge".to_string())?; + let nonce = nonce + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::(); + let authority = loopback_authority(&host, port); + let challenge = format!( + "GET /healthz HTTP/1.1\r\nHost: {authority}\r\nX-NeMo-Relay-Bootstrap-Fingerprint: {bootstrap_fingerprint}\r\nX-NeMo-Relay-Bootstrap-Nonce: {nonce}\r\nConnection: keep-alive\r\n\r\n" + ); + stream + .write_all(challenge.as_bytes()) + .map_err(|error| format!("failed to authenticate managed sidecar shutdown: {error}"))?; + let (health_headers, health_body) = read_http_message(&mut stream, 16 * 1024) + .map_err(|error| format!("failed to read managed sidecar shutdown proof: {error}"))?; + if classify_health_response( + &health_headers, + &health_body, + Some((bootstrap_fingerprint, nonce.as_str(), key.as_ref())), + ) + .0 != RelayHealth::Compatible + { + return Err("managed sidecar did not authenticate the shutdown connection".into()); + } + if http_header(&health_headers, "connection") + .is_some_and(|value| value.eq_ignore_ascii_case("close")) + { + return Err("managed sidecar closed the authenticated shutdown connection".into()); + } + let request = format!( + "POST /bootstrap/shutdown HTTP/1.1\r\nHost: {}\r\nX-NeMo-Relay-Bootstrap-Token: {token}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + authority + ); + stream + .write_all(request.as_bytes()) + .map_err(|error| format!("failed to request managed sidecar shutdown: {error}"))?; + let mut response = Vec::new(); + stream + .take(16 * 1024) + .read_to_end(&mut response) + .map_err(|error| format!("failed to read managed sidecar shutdown response: {error}"))?; + let Some((headers, _)) = split_http_response(&response) else { + return Err("managed sidecar returned a malformed shutdown response".into()); + }; + if headers.starts_with(b"HTTP/1.1 204") || headers.starts_with(b"HTTP/1.0 204") { + Ok(()) + } else { + Err(format!( + "managed sidecar rejected shutdown: {}", + String::from_utf8_lossy(headers) + .lines() + .next() + .unwrap_or("unknown response") + )) + } +} + +fn cached_bootstrap_challenge_key() -> Result, String> { + let state = crate::bootstrap::state::state_dir()?; + let cache = CHALLENGE_KEY_CACHE.get_or_init(|| Mutex::new(HashMap::new())); + let mut cache = cache + .lock() + .map_err(|_| "Relay bootstrap challenge key cache is poisoned".to_string())?; + if let Some(key) = cache.get(&state) { + return Ok(Arc::clone(key)); + } + let key = Arc::new(BootstrapChallengeKey::load().map_err(|error| error.to_string())?); + cache.insert(state, Arc::clone(&key)); + Ok(key) +} + +fn cached_tls_identity() -> Result, String> { + let state = crate::bootstrap::state::state_dir()?; + let cache = TLS_IDENTITY_CACHE.get_or_init(|| Mutex::new(HashMap::new())); + let mut cache = cache + .lock() + .map_err(|_| "Relay TLS identity cache is poisoned".to_string())?; + if let Some(identity) = cache.get(&state) { + return Ok(Arc::clone(identity)); + } + let identity = Arc::new(crate::gateway::tls::RelayTlsIdentity::load()?); + cache.insert(state, Arc::clone(&identity)); + Ok(identity) +} + +fn http_header<'a>(headers: &'a [u8], name: &str) -> Option<&'a str> { + headers.split(|byte| *byte == b'\n').find_map(|line| { + let line = std::str::from_utf8(line).ok()?.trim_end_matches('\r'); + let (candidate, value) = line.split_once(':')?; + candidate.eq_ignore_ascii_case(name).then(|| value.trim()) + }) +} + +fn http_status(headers: &[u8]) -> Option { + let line = headers.split(|byte| *byte == b'\n').next()?; + let line = std::str::from_utf8(line).ok()?.trim_end_matches('\r'); + let mut fields = line.split_ascii_whitespace(); + matches!(fields.next(), Some("HTTP/1.1" | "HTTP/1.0")) + .then(|| fields.next()?.parse().ok()) + .flatten() +} + +fn classify_health_response( + headers: &[u8], + body: &[u8], + challenge: Option<(&str, &str, &BootstrapChallengeKey)>, +) -> (RelayHealth, Option) { + let Ok(body) = serde_json::from_slice::(body) else { + return (RelayHealth::Foreign, None); + }; + if body.get("service").and_then(Value::as_str) != Some("nemo-relay") + || body.get("bootstrap_protocol").and_then(Value::as_u64) + != Some(BOOTSTRAP_PROTOCOL_VERSION) + { + return (RelayHealth::Foreign, None); + } + if http_status(headers) == Some(409) { + return (RelayHealth::Incompatible, None); + } + if http_status(headers) != Some(200) || body.get("status").and_then(Value::as_str) != Some("ok") + { + return (RelayHealth::Foreign, None); + } + if let Some((fingerprint, nonce, key)) = challenge { + let Some(proof) = http_header(headers, "x-nemo-relay-bootstrap-proof") else { + return (RelayHealth::Foreign, None); + }; + if !key.verify(fingerprint, nonce, proof) { + return (RelayHealth::Foreign, None); + } + } + let Some(instance_id) = body + .get("instance_id") + .and_then(Value::as_str) + .filter(|instance_id| !instance_id.is_empty() && instance_id.len() <= 128) + else { + return (RelayHealth::Foreign, None); + }; + (RelayHealth::Compatible, Some(instance_id.to_owned())) +} + +fn connect_loopback( + addresses: impl IntoIterator, + timeout: Duration, +) -> std::io::Result { + let mut last_error = None; + for address in addresses + .into_iter() + .filter(|address| address.ip().is_loopback()) + { + match TcpStream::connect_timeout(&address, timeout) { + Ok(stream) => return Ok(stream), + Err(error) => last_error = Some(error), + } + } + Err(last_error.unwrap_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::AddrNotAvailable, + "gateway URL resolved to no loopback socket addresses", + ) + })) +} + +fn read_http_message( + stream: &mut impl Read, + max_body_bytes: usize, +) -> std::io::Result<(Vec, Vec)> { + const MAX_HEADER_BYTES: usize = 16 * 1024; + + let mut response = Vec::new(); + let header_end = loop { + if let Some(index) = response.windows(4).position(|window| window == b"\r\n\r\n") { + break index + 4; + } + if response.len() >= MAX_HEADER_BYTES { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "HTTP response headers exceed the Relay limit", + )); + } + let mut chunk = [0_u8; 1024]; + let read = stream.read(&mut chunk)?; + if read == 0 { + return Err(std::io::Error::new( + std::io::ErrorKind::UnexpectedEof, + "HTTP response ended before its headers", + )); + } + response.extend_from_slice(&chunk[..read]); + }; + let headers = response[..header_end - 4].to_vec(); + if http_header(&headers, "transfer-encoding").is_some() { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "chunked HTTP responses are not supported by the verified Relay transport", + )); + } + if http_status(&headers) == Some(101) { + return Ok((headers, Vec::new())); + } + let content_length = http_header(&headers, "content-length") + .ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::InvalidData, + "HTTP response omitted Content-Length", + ) + })? + .parse::() + .map_err(|_| { + std::io::Error::new( + std::io::ErrorKind::InvalidData, + "HTTP response had an invalid Content-Length", + ) + })?; + if content_length > max_body_bytes { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("HTTP response exceeds the {max_body_bytes}-byte Relay limit"), + )); + } + let mut body = response[header_end..].to_vec(); + if body.len() > content_length { + body.truncate(content_length); + } + while body.len() < content_length { + let mut chunk = [0_u8; 4096]; + let needed = (content_length - body.len()).min(chunk.len()); + let read = stream.read(&mut chunk[..needed])?; + if read == 0 { + return Err(std::io::Error::new( + std::io::ErrorKind::UnexpectedEof, + "HTTP response ended before its declared body", + )); + } + body.extend_from_slice(&chunk[..read]); + } + Ok((headers, body)) +} + +fn split_http_response(response: &[u8]) -> Option<(&[u8], &[u8])> { + response + .windows(4) + .position(|window| window == b"\r\n\r\n") + .map(|index| (&response[..index], &response[index + 4..])) +} + +pub(crate) fn parse_loopback_url(url: &str) -> Result<(String, u16), String> { + let parsed = Url::parse(url) + .map_err(|error| format!("invalid shared gateway loopback URL {url}: {error}"))?; + if parsed.scheme() != "http" { + return Err(format!( + "shared gateway recovery only supports http loopback URLs: {url}" + )); + } + let host = parsed + .host_str() + .ok_or_else(|| format!("missing host in gateway URL: {url}"))? + .trim_start_matches('[') + .trim_end_matches(']'); + let loopback = host.eq_ignore_ascii_case("localhost") + || host + .parse::() + .is_ok_and(|address| address.is_loopback()); + if !loopback { + return Err(format!( + "shared gateway recovery only supports loopback gateway URLs: {url}" + )); + } + let port = parsed + .port() + .ok_or_else(|| format!("missing port in gateway URL: {url}"))?; + Ok((host.to_string(), port)) +} + +pub(crate) fn loopback_bind(url: &str) -> Result { + let (host, port) = parse_loopback_url(url)?; + let address = if host.eq_ignore_ascii_case("localhost") { + std::net::IpAddr::V4(Ipv4Addr::LOCALHOST) + } else { + host.parse::() + .map_err(|error| format!("invalid loopback address in gateway URL {url}: {error}"))? + }; + Ok(SocketAddr::new(address, port)) +} + +pub(crate) fn loopback_authority(host: &str, port: u16) -> String { + if host.contains(':') { + format!("[{host}]:{port}") + } else { + format!("{host}:{port}") + } +} + +#[cfg(test)] +#[path = "../../tests/coverage/shared/gateway_client_tests.rs"] +mod tests; diff --git a/crates/cli/src/gateway.rs b/crates/cli/src/gateway/mod.rs similarity index 64% rename from crates/cli/src/gateway.rs rename to crates/cli/src/gateway/mod.rs index 06f4d1885..a9d39adbf 100644 --- a/crates/cli/src/gateway.rs +++ b/crates/cli/src/gateway/mod.rs @@ -1,8 +1,17 @@ // SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +pub(crate) mod client; +mod request; +mod response; +mod routes; +pub(crate) mod tls; + +use request::*; +use response::*; +use routes::*; + use std::collections::BTreeMap; -use std::error::Error; use std::sync::{Arc, Mutex}; use async_stream::stream; @@ -10,7 +19,6 @@ use axum::body::{Body, Bytes}; use axum::extract::State; use axum::http::{HeaderMap, HeaderName, HeaderValue, Method, Request, Response, StatusCode}; use futures_util::StreamExt; -use http_body_util::LengthLimitError; use nemo_relay::api::llm::{ LlmCallExecuteParams, LlmRequest, LlmStreamCallExecuteParams, llm_call_execute, llm_stream_call_execute, @@ -25,13 +33,17 @@ use nemo_relay::codec::resolve::{ use nemo_relay::codec::streaming::StreamingCodec; use nemo_relay::codec::traits::LlmResponseCodec; use nemo_relay::error::{FlowError, UpstreamFailure, UpstreamFailureClass}; -use serde_json::{Map, Value, json}; +use serde_json::{Value, json}; -use crate::alignment::{self, GatewayRouteKind}; -use crate::config::header_string; +use crate::agents::shared::alignment::{self, GatewayRouteKind}; +use crate::configuration::BOOTSTRAP_CLIENT_TOKEN_HEADER; use crate::error::CliError; use crate::server::AppState; -use crate::session::{GatewayCallPrep, LlmGatewayStart, SessionManager}; +use crate::sessions::{GatewayCallPrep, GatewaySessionFinish, SessionManager}; + +#[cfg(test)] +#[path = "../../tests/coverage/shared/gateway_tests.rs"] +mod tests; const INTERNAL_DISPATCH_URL_HEADER: &str = "x-nemo-relay-internal-dispatch-url"; const INTERNAL_DISPATCH_ROUTE_HEADER: &str = "x-nemo-relay-internal-dispatch-route"; @@ -56,7 +68,9 @@ pub(crate) async fn passthrough( request: Request, ) -> Result, CliError> { state.touch(); - let prepared = prepare_gateway_request(&state.config, request).await?; + let allow_environment_provider_auth = state.allows_environment_provider_auth(request.headers()); + let prepared = + prepare_gateway_request(&state.config, request, allow_environment_provider_auth).await?; let prep = state .sessions .prepare_gateway_call(&prepared.headers, build_llm_gateway_start(&prepared)) @@ -64,129 +78,6 @@ pub(crate) async fn passthrough( run_managed_gateway(state, prepared, prep).await } -struct PreparedGatewayRequest { - method: Method, - headers: HeaderMap, - path: String, - provider: ProviderRoute, - upstream_url: String, - body_bytes: Bytes, - request_json: Value, - streaming: bool, -} - -// Validates the gateway route, buffers the request body exactly once, and derives the metadata used -// for both upstream forwarding and NeMo Relay LLM start events. Provider JSON parse failures are not -// request failures because the gateway still forwards raw bytes unchanged. -async fn prepare_gateway_request( - config: &crate::config::GatewayConfig, - request: Request, -) -> Result { - let (parts, body) = request.into_parts(); - let provider = ProviderRoute::from_path(parts.uri.path()).ok_or_else(|| { - CliError::InvalidPayload(format!("unsupported gateway path {}", parts.uri.path())) - })?; - let body_bytes = axum::body::to_bytes(body, config.max_passthrough_body_bytes) - .await - .map_err(passthrough_body_error)?; - let request_json = serde_json::from_slice::(&body_bytes).unwrap_or(Value::Null); - let path_and_query = parts - .uri - .path_and_query() - .map(|p| p.as_str()) - .unwrap_or(parts.uri.path()); - let upstream_url = gateway_upstream_url_override(provider, &parts.headers, path_and_query) - .unwrap_or_else(|| provider.upstream_url(config, path_and_query)); - let streaming = request_json - .get("stream") - .and_then(Value::as_bool) - .unwrap_or(false); - let mut headers = parts.headers; - strip_internal_dispatch_headers(&mut headers); - Ok(PreparedGatewayRequest { - method: parts.method, - headers, - path: parts.uri.path().to_string(), - provider, - upstream_url, - body_bytes, - request_json, - streaming, - }) -} - -fn passthrough_body_error(error: axum::Error) -> CliError { - if error.source().is_some_and(|source| { - source.is::() - || source - .source() - .is_some_and(|source| source.is::()) - }) { - CliError::PayloadTooLarge(error.to_string()) - } else { - CliError::InvalidPayload(error.to_string()) - } -} - -// Builds the [`LlmGatewayStart`] payload from a prepared request. Identifier resolution is shared -// across streaming and non-streaming paths so correlation behavior is consistent for every route. -// Provider-specific fallbacks are resolved here, before request execution leaves the gateway path, -// because the later runtime-managed LLM call only sees this normalized start payload. -fn build_llm_gateway_start(request: &PreparedGatewayRequest) -> LlmGatewayStart { - LlmGatewayStart { - // Explicit NeMo Relay headers still win, but alignment can recover agent-native session - // signals when available. Applies to Claude Code's session header and Codex's Responses - // prompt-cache thread id today. - session_id: gateway_session_id(&request.headers, &request.request_json, request.provider), - provider: request.provider.name().to_string(), - model_name: request - .request_json - .get("model") - .and_then(Value::as_str) - .map(ToOwned::to_owned), - // Subagent ownership is intentionally header-only at the gateway layer. Body fields can be - // provider payload content rather than scope identity, so the session layer handles other - // ownership hints. - subagent_id: gateway_subagent_id(&request.headers), - conversation_id: gateway_identifier( - &request.headers, - &request.request_json, - "x-nemo-relay-conversation-id", - &[ - &["conversation_id"], - &["conversationId"], - &["conversation", "id"], - ], - ), - generation_id: gateway_identifier( - &request.headers, - &request.request_json, - "x-nemo-relay-generation-id", - &[&["generation_id"], &["generationId"], &["generation", "id"]], - ), - request_id: gateway_identifier( - &request.headers, - &request.request_json, - "x-nemo-relay-request-id", - &[ - &["request_id"], - &["requestId"], - &["request", "id"], - &["metadata", "request_id"], - ], - ) - // Preserve a transport request id as a weak fallback for debugging even when the provider - // body does not expose an LLM request id. - .or_else(|| header_string(&request.headers, "x-request-id")), - request: LlmRequest { - headers: observable_headers(&request.headers), - content: request.request_json.clone(), - }, - streaming: request.streaming, - metadata: json!({ "gateway_path": request.path }), - } -} - // Captures upstream HTTP status and response headers from inside the managed `func`. The runtime's // LLM execution callback returns only a Json (or Json stream), so the outer gateway needs a side // channel to recover the bytes the client expects. @@ -206,7 +97,7 @@ async fn run_managed_gateway( ) -> Result, CliError> { if prep.bypass_managed_pipeline { let session_id = prep.session_id.clone(); - let prune_empty_session = prep.prune_empty_session_on_finish; + let session_finish = prep.session_finish; let model = prep.model_name.as_deref().unwrap_or(""); eprintln!( "nemo-relay CLI gateway: bypassing managed LLM observability for Claude Code startup probe session={session_id} provider={} model={model}", @@ -214,7 +105,7 @@ async fn run_managed_gateway( ); state .sessions - .finish_gateway_call(&session_id, prune_empty_session) + .finish_gateway_call(&session_id, session_finish) .await; return run_unmanaged_gateway(state, prepared).await; } @@ -240,7 +131,7 @@ async fn run_unmanaged_gateway( &prepared.body_bytes, &prepared.headers, None, - prepared.provider, + ProviderForwarding::new(prepared.provider, prepared.allow_environment_provider_auth), ) .await?; let status = response.status(); @@ -299,7 +190,7 @@ async fn run_managed_buffered( model_name, owner_subagent_id, bypass_managed_pipeline: _, - prune_empty_session_on_finish: _, + session_finish, } = prep; let provider_for_event = provider_name.clone(); let params = LlmCallExecuteParams::builder() @@ -321,7 +212,10 @@ async fn run_managed_buffered( .sessions .record_gateway_response_hints(&session_id, owner_subagent_id, response_json) .await; - state.sessions.finish_gateway_call(&session_id, false).await; + state + .sessions + .finish_gateway_call(&session_id, session_finish) + .await; let (status, headers) = upstream_info .lock() .expect("upstream info lock poisoned") @@ -335,7 +229,10 @@ async fn run_managed_buffered( build_response(status, headers, Body::from(bytes)) } Err(error) => { - state.sessions.finish_gateway_call(&session_id, false).await; + state + .sessions + .finish_gateway_call(&session_id, session_finish) + .await; Err(translate_runtime_error(error, &upstream_error)) } } @@ -356,7 +253,8 @@ fn build_buffered_func( let url = prepared.upstream_url.clone(); let body_bytes = prepared.body_bytes.clone(); let headers = prepared.headers.clone(); - let route = prepared.provider; + let forwarding = + ProviderForwarding::new(prepared.provider, prepared.allow_environment_provider_auth); Arc::new(move |request| { let http = http.clone(); let method = method.clone(); @@ -375,7 +273,7 @@ fn build_buffered_func( &body_bytes, &headers, Some(&request), - route, + forwarding, ) .await { @@ -443,9 +341,10 @@ async fn run_managed_streaming( // collector and finalizer for managed streaming, so without a codec we cannot use the managed // pipeline. This keeps non-LLM streaming paths working while typed codecs remain optional. let Some(streaming_codec) = codecs.streaming else { + let session_finish = prep.session_finish; state .sessions - .finish_gateway_call(&prep.session_id, false) + .finish_gateway_call(&prep.session_id, session_finish) .await; return passthrough_streaming(state, prepared).await; }; @@ -472,7 +371,7 @@ async fn run_managed_streaming( model_name, owner_subagent_id, bypass_managed_pipeline: _, - prune_empty_session_on_finish: _, + session_finish, } = prep; let params = LlmStreamCallExecuteParams::builder() .name(provider_name) @@ -495,7 +394,10 @@ async fn run_managed_streaming( let json_stream = match json_stream_result { Ok(json_stream) => json_stream, Err(error) => { - state.sessions.finish_gateway_call(&session_id, false).await; + state + .sessions + .finish_gateway_call(&session_id, session_finish) + .await; return Err(translate_runtime_error(error, &upstream_error)); } }; @@ -511,6 +413,7 @@ async fn run_managed_streaming( session_id.clone(), owner_subagent_id, final_response, + session_finish, ); // Streamed responses are finalized inside the runtime stream wrapper. The small finalizer tap @@ -533,7 +436,8 @@ fn build_streaming_func( let url = prepared.upstream_url.clone(); let body_bytes = prepared.body_bytes.clone(); let headers = prepared.headers.clone(); - let route = prepared.provider; + let forwarding = + ProviderForwarding::new(prepared.provider, prepared.allow_environment_provider_auth); Arc::new(move |request| { let http = http.clone(); let method = method.clone(); @@ -551,7 +455,7 @@ fn build_streaming_func( &body_bytes, &headers, Some(&request), - route, + forwarding, ) .await { @@ -636,9 +540,16 @@ fn client_sse_body( session_id: String, owner_subagent_id: Option, final_response: Arc>>, + session_finish: GatewaySessionFinish, ) -> Body { let mut json_stream = json_stream; - let mut guard = GatewayCallGuard::new(sessions, session_id, owner_subagent_id, final_response); + let mut guard = GatewayCallGuard::new( + sessions, + session_id, + owner_subagent_id, + final_response, + session_finish, + ); let stream = stream! { while let Some(item) = json_stream.next().await { match item { @@ -669,6 +580,7 @@ struct GatewayCallGuard { session_id: String, owner_subagent_id: Option, final_response: Arc>>, + session_finish: GatewaySessionFinish, } impl GatewayCallGuard { @@ -677,12 +589,14 @@ impl GatewayCallGuard { session_id: String, owner_subagent_id: Option, final_response: Arc>>, + session_finish: GatewaySessionFinish, ) -> Self { Self { sessions: Some(sessions), session_id, owner_subagent_id, final_response, + session_finish, } } @@ -693,20 +607,35 @@ impl GatewayCallGuard { .lock() .expect("stream final response lock poisoned") .take(); - if let Some(response) = response { - sessions - .record_gateway_response_hints( - &self.session_id, - self.owner_subagent_id.clone(), - response, - ) - .await; - } - sessions.finish_gateway_call(&self.session_id, false).await; + complete_gateway_call( + sessions, + self.session_id.clone(), + self.owner_subagent_id.clone(), + response, + self.session_finish, + ) + .await; } } } +async fn complete_gateway_call( + sessions: SessionManager, + session_id: String, + owner_subagent_id: Option, + response: Option, + session_finish: GatewaySessionFinish, +) { + if let Some(response) = response { + sessions + .record_gateway_response_hints(&session_id, owner_subagent_id, response) + .await; + } + sessions + .finish_gateway_call(&session_id, session_finish) + .await; +} + impl Drop for GatewayCallGuard { fn drop(&mut self) { let Some(sessions) = self.sessions.take() else { @@ -714,20 +643,27 @@ impl Drop for GatewayCallGuard { }; let session_id = self.session_id.clone(); let owner_subagent_id = self.owner_subagent_id.clone(); + let session_finish = self.session_finish; let response = self .final_response .lock() .expect("stream final response lock poisoned") .take(); + let cleanup = complete_gateway_call( + sessions, + session_id, + owner_subagent_id, + response, + session_finish, + ); if let Ok(handle) = tokio::runtime::Handle::try_current() { - handle.spawn(async move { - if let Some(response) = response { - sessions - .record_gateway_response_hints(&session_id, owner_subagent_id, response) - .await; - } - sessions.finish_gateway_call(&session_id, false).await; - }); + handle.spawn(cleanup); + } else { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("gateway cleanup runtime should build") + .block_on(cleanup); } } } @@ -760,19 +696,34 @@ async fn forward_upstream_request( body_bytes: &Bytes, headers: &HeaderMap, effective_request: Option<&LlmRequest>, - route: ProviderRoute, + forwarding: ProviderForwarding, ) -> Result { - let effective = effective_dispatch_request(body_bytes, headers, effective_request, url, route); - let sanitized = strip_replaceable_agent_auth_headers(&effective.headers, effective.route); + let effective = effective_dispatch_request( + body_bytes, + headers, + effective_request, + url, + forwarding.route, + ); + let sanitized = strip_replaceable_agent_auth_headers( + &effective.headers, + effective.route, + forwarding.allow_environment_provider_auth, + ); let mut upstream = http .request(method.clone(), &effective.url) .body(effective.body_bytes.clone()); for (name, value) in &sanitized { - if should_forward_request_header(name) { + if should_forward_request_header(name, &sanitized) { upstream = upstream.header(name, value); } } - upstream = inject_provider_auth(upstream, effective.route, &sanitized); + upstream = inject_provider_auth( + upstream, + effective.route, + &sanitized, + forwarding.allow_environment_provider_auth, + ); upstream.send().await } @@ -838,12 +789,14 @@ fn effective_dispatch_request( }; let mut override_url = None; let mut override_route = None; + let mut dispatch_route_header_seen = false; for (name, value) in &request.headers { if name.eq_ignore_ascii_case(INTERNAL_DISPATCH_URL_HEADER) { override_url = json_header_string(value); continue; } if name.eq_ignore_ascii_case(INTERNAL_DISPATCH_ROUTE_HEADER) { + dispatch_route_header_seen = true; override_route = json_header_string(value) .and_then(|value| ProviderRoute::from_dispatch_override(&value)); continue; @@ -862,7 +815,11 @@ fn effective_dispatch_request( EffectiveUpstreamRequest { body_bytes, headers, - url: override_url.unwrap_or_else(|| url.to_string()), + url: if dispatch_route_header_seen && override_route.is_none() { + url.to_string() + } else { + override_url.unwrap_or_else(|| url.to_string()) + }, route: override_route.unwrap_or(route), } } @@ -904,8 +861,15 @@ fn inject_provider_auth( builder: reqwest::RequestBuilder, route: ProviderRoute, inbound: &HeaderMap, + allow_environment_provider_auth: bool, ) -> reqwest::RequestBuilder { - inject_provider_auth_with_env(builder, route, inbound, |key| std::env::var(key).ok()) + inject_provider_auth_with_env( + builder, + route, + inbound, + allow_environment_provider_auth, + |key| std::env::var(key).ok(), + ) } // Pure variant exposed for tests. The env lookup is injected so cases can be exercised without @@ -914,11 +878,15 @@ fn inject_provider_auth_with_env( builder: reqwest::RequestBuilder, route: ProviderRoute, inbound: &HeaderMap, + allow_environment_provider_auth: bool, env_lookup: F, ) -> reqwest::RequestBuilder where F: Fn(&str) -> Option, { + if !allow_environment_provider_auth { + return builder; + } let already_authed = inbound.contains_key(http::header::AUTHORIZATION) || inbound.contains_key("x-api-key") || inbound.contains_key("api-key") @@ -966,7 +934,7 @@ async fn passthrough_streaming( &prepared.body_bytes, &prepared.headers, None, - prepared.provider, + ProviderForwarding::new(prepared.provider, prepared.allow_environment_provider_auth), ) .await?; let status = response.status(); @@ -1096,7 +1064,7 @@ pub(crate) async fn models( request: Request, ) -> Result, CliError> { state.touch(); - let (parts, _body) = request.into_parts(); + let (mut parts, _body) = request.into_parts(); if parts.method != Method::GET { return build_response( StatusCode::METHOD_NOT_ALLOWED, @@ -1110,300 +1078,35 @@ pub(crate) async fn models( .path_and_query() .map(|p| p.as_str()) .unwrap_or(parts.uri.path()); - let upstream_url = gateway_upstream_url_override(provider, &parts.headers, path_and_query) - .unwrap_or_else(|| provider.upstream_url(&state.config, path_and_query)); - let sanitized = strip_replaceable_agent_auth_headers(&parts.headers, provider); + let allow_environment_provider_auth = state.allows_environment_provider_auth(&parts.headers); + parts.headers.remove(BOOTSTRAP_CLIENT_TOKEN_HEADER); + let upstream_url = gateway_upstream_url_override( + provider, + &parts.headers, + path_and_query, + allow_environment_provider_auth, + ) + .unwrap_or_else(|| provider.upstream_url(&state.config, path_and_query)); + let sanitized = strip_replaceable_agent_auth_headers( + &parts.headers, + provider, + allow_environment_provider_auth, + ); let mut upstream = state.http.get(upstream_url); for (name, value) in &sanitized { - if should_forward_request_header(name) { + if should_forward_request_header(name, &sanitized) { upstream = upstream.header(name, value); } } - upstream = inject_provider_auth(upstream, provider, &sanitized); + upstream = inject_provider_auth( + upstream, + provider, + &sanitized, + allow_environment_provider_auth, + ); let upstream_response = upstream.send().await?; let status = upstream_response.status(); let headers = response_headers(upstream_response.headers()); let bytes = upstream_response.bytes().await?; build_response(status, headers, Body::from(bytes)) } - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum ProviderRoute { - OpenAiResponses, - OpenAiChatCompletions, - OpenAiModels, - AnthropicMessages, - AnthropicCountTokens, -} - -impl ProviderRoute { - // Maps public gateway paths to known upstream provider routes. Unsupported paths return `None` - // so the caller can fail as a bad hook/gateway payload instead of constructing arbitrary URLs. - fn from_path(path: &str) -> Option { - match path { - "/responses" => Some(Self::OpenAiResponses), - "/v1/responses" => Some(Self::OpenAiResponses), - "/chat/completions" => Some(Self::OpenAiChatCompletions), - "/v1/chat/completions" => Some(Self::OpenAiChatCompletions), - "/models" => Some(Self::OpenAiModels), - "/v1/models" => Some(Self::OpenAiModels), - "/v1/messages" => Some(Self::AnthropicMessages), - "/v1/messages/count_tokens" => Some(Self::AnthropicCountTokens), - _ => None, - } - } - - fn from_dispatch_override(value: &str) -> Option { - match value { - "openai_chat" - | "openai_chat_completions" - | "openai.chat_completions" - | "/v1/chat/completions" => Some(Self::OpenAiChatCompletions), - "openai_responses" | "openai.responses" | "/v1/responses" => { - Some(Self::OpenAiResponses) - } - "anthropic_messages" | "anthropic.messages" | "/v1/messages" => { - Some(Self::AnthropicMessages) - } - _ => None, - } - } - - const fn provider_surface(self) -> Option { - match self { - Self::OpenAiResponses => Some(ProviderSurface::OpenAIResponses), - Self::OpenAiChatCompletions => Some(ProviderSurface::OpenAIChat), - Self::AnthropicMessages => Some(ProviderSurface::AnthropicMessages), - Self::AnthropicCountTokens | Self::OpenAiModels => None, - } - } - - // Returns the provider route name recorded on managed LLM events. These names split OpenAI API - // variants because their request/response schemas differ even when they share a base URL, and - // they double as codec hints for ambiguous provider request shapes. - const fn name(self) -> &'static str { - self.alignment_route().name() - } - - // Builds the upstream URL by combining the configured provider base with the original path and - // query string. Trailing slashes are stripped from the base to avoid double-slash variants in - // configured enterprise or local proxy endpoints. - fn upstream_url(self, config: &crate::config::GatewayConfig, path_and_query: &str) -> String { - let base = match self { - Self::OpenAiResponses | Self::OpenAiChatCompletions | Self::OpenAiModels => { - config.openai_base_url.as_str() - } - Self::AnthropicMessages | Self::AnthropicCountTokens => { - config.anthropic_base_url.as_str() - } - }; - self.upstream_url_with_base(base, path_and_query) - } - - // Like `upstream_url` but with an explicit base URL. This keeps OpenAI `/v1` normalization in - // one place for configured public, enterprise, or local proxy bases. - fn upstream_url_with_base(self, base: &str, path_and_query: &str) -> String { - let base = base.trim_end_matches('/'); - let path_and_query = match self { - Self::OpenAiResponses | Self::OpenAiChatCompletions | Self::OpenAiModels => { - normalize_openai_path_for_base(base, path_and_query) - } - _ => path_and_query.to_string(), - }; - format!("{base}{path_and_query}") - } - - // Narrows gateway routing to the smaller taxonomy used by trace alignment. Keeping this - // conversion here prevents provider-specific alignment code from depending on gateway URL - // routing internals. - const fn alignment_route(self) -> GatewayRouteKind { - match self { - Self::OpenAiResponses => GatewayRouteKind::OpenAiResponses, - Self::OpenAiChatCompletions => GatewayRouteKind::OpenAiChatCompletions, - Self::OpenAiModels => GatewayRouteKind::OpenAiModels, - Self::AnthropicMessages => GatewayRouteKind::AnthropicMessages, - Self::AnthropicCountTokens => GatewayRouteKind::AnthropicCountTokens, - } - } -} - -fn normalize_openai_path_for_base(base: &str, path_and_query: &str) -> String { - match (base.ends_with("/v1"), path_and_query.starts_with("/v1/")) { - (true, true) => path_and_query - .strip_prefix("/v1") - .expect("path was checked to start with /v1") - .to_string(), - (false, false) => format!("/v1{path_and_query}"), - _ => path_and_query.to_string(), - } -} - -// Gives alignment adapters a chance to choose an agent-native upstream before default provider -// routing runs. Today this supports Codex ChatGPT auth; future harness fallbacks should stay in -// alignment rather than adding provider-shaped checks here. -fn gateway_upstream_url_override( - route: ProviderRoute, - headers: &HeaderMap, - path_and_query: &str, -) -> Option { - gateway_upstream_url_override_with_openai_key_state( - route, - headers, - path_and_query, - env_var_is_nonempty("OPENAI_API_KEY"), - ) -} - -fn gateway_upstream_url_override_with_openai_key_state( - route: ProviderRoute, - headers: &HeaderMap, - path_and_query: &str, - has_openai_replacement_key: bool, -) -> Option { - alignment::gateway_upstream_url_override( - headers, - route.alignment_route(), - path_and_query, - has_openai_replacement_key, - ) -} - -// Lets alignment adapters strip agent-native credentials only when the gateway can replace them -// with standard provider API keys. Whitespace-only env vars are treated as missing because -// forwarding an empty bearer value only replaces one authentication failure with another. -fn strip_replaceable_agent_auth_headers(headers: &HeaderMap, route: ProviderRoute) -> HeaderMap { - strip_replaceable_agent_auth_headers_with_openai_key_state( - headers, - route, - env_var_is_nonempty("OPENAI_API_KEY"), - ) -} - -fn strip_replaceable_agent_auth_headers_with_openai_key_state( - headers: &HeaderMap, - route: ProviderRoute, - has_openai_replacement_key: bool, -) -> HeaderMap { - alignment::gateway_forward_headers(headers, route.alignment_route(), has_openai_replacement_key) -} - -fn env_var_is_nonempty(name: &str) -> bool { - std::env::var(name) - .ok() - .filter(|value| !value.trim().is_empty()) - .is_some() -} - -// Delegates provider-specific session fallbacks to `alignment` so request construction stays -// generic and each coding-agent quirk has one documented adapter. -fn gateway_session_id(headers: &HeaderMap, body: &Value, route: ProviderRoute) -> Option { - alignment::gateway_session_id(headers, body, route.alignment_route()) -} - -fn gateway_subagent_id(headers: &HeaderMap) -> Option { - alignment::gateway_subagent_id(headers) -} - -// Keeps the gateway-facing helper local for tests while the generic extraction pattern lives in -// `alignment`. -fn gateway_identifier( - headers: &HeaderMap, - body: &Value, - header_name: &'static str, - body_paths: &[&[&str]], -) -> Option { - alignment::gateway_identifier(headers, body, header_name, body_paths) -} - -// Copies only non-sensitive, forwardable request headers into LLM request metadata. This preserves -// correlation headers while excluding credentials and hop-by-hop transport details. -fn observable_headers(headers: &HeaderMap) -> Map { - let mut output = Map::new(); - for (name, value) in headers { - if should_record_header(name) - && let Ok(value) = value.to_str() - { - output.insert(name.as_str().to_string(), json!(value)); - } - } - output -} - -// Copies upstream response headers except hop-by-hop transport headers that Axum/hyper must manage -// for the downstream connection. Multiple values are appended to preserve provider behavior. -// Content-Length is also dropped because the gateway re-encodes streaming responses and the -// upstream-reported length will not match the bytes the client sees. -fn response_headers(headers: &HeaderMap) -> HeaderMap { - let mut output = HeaderMap::new(); - for (name, value) in headers { - if !is_hop_by_hop(name) && name != http::header::CONTENT_LENGTH { - output.append(name.clone(), value.clone()); - } - } - output -} - -// Reconstructs an Axum response from upstream status, filtered headers, and the selected body. All -// builder errors are converted into gateway HTTP errors rather than panics. -fn build_response( - status: StatusCode, - headers: HeaderMap, - body: Body, -) -> Result, CliError> { - let mut builder = Response::builder().status(status); - for (name, value) in &headers { - builder = builder.header(name, value); - } - Ok(builder.body(body)?) -} - -// Allows provider request headers through unless they are transport-owned or must be recalculated -// for the forwarded body. Host and content length are intentionally excluded because reqwest sets -// them for the upstream connection. -fn should_forward_request_header(name: &HeaderName) -> bool { - !is_hop_by_hop(name) - && name != http::header::HOST - && name != http::header::CONTENT_LENGTH - // Strip Accept-Encoding so upstreams return identity-encoded bodies; otherwise the - // observability capture (`output.value` on LLM spans, ATIF trajectory bodies) records - // gzip/br/zstd bytes that downstream consumers can't read. Bandwidth cost is paid only - // on the gateway-upstream hop. The client never asked for the encoding it would have - // received from upstream, so its decoders never trigger. - && name != http::header::ACCEPT_ENCODING -} - -// Allows headers into observability metadata only after removing credentials and provider API keys. -// The forwarding filter runs first so hop-by-hop transport headers are also excluded from recorded -// LLM request attributes. The credential blocklist covers the four canonical cases we see in -// practice: `Authorization` (most providers), `Cookie` (session credentials), `x-api-key` (OpenAI -// SDK and similar), `anthropic-api-key` (Anthropic), and the generic `api-key` alias used by some -// providers/proxies (e.g., Azure OpenAI). `HeaderName::as_str()` already returns the canonical -// lowercase form so string comparisons are case-insensitive by construction. -fn should_record_header(name: &HeaderName) -> bool { - should_forward_request_header(name) - && name != http::header::AUTHORIZATION - && name != http::header::COOKIE - && name.as_str() != "x-api-key" - && name.as_str() != "api-key" - && name.as_str() != "anthropic-api-key" -} - -// Identifies headers that describe a single transport hop and therefore must not be proxied across -// the client-gateway-upstream boundary. -fn is_hop_by_hop(name: &HeaderName) -> bool { - matches!( - name.as_str(), - "connection" - | "keep-alive" - | "proxy-authenticate" - | "proxy-authorization" - | "te" - | "trailer" - | "transfer-encoding" - | "upgrade" - ) -} - -#[cfg(test)] -#[path = "../tests/coverage/gateway_tests.rs"] -mod tests; diff --git a/crates/cli/src/gateway/request.rs b/crates/cli/src/gateway/request.rs new file mode 100644 index 000000000..a56fccaa8 --- /dev/null +++ b/crates/cli/src/gateway/request.rs @@ -0,0 +1,137 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Gateway request validation, buffering, and normalized LLM start construction. + +use std::error::Error; + +use axum::body::{Body, Bytes}; +use axum::http::{HeaderMap, Method, Request}; +use http_body_util::LengthLimitError; +use nemo_relay::api::llm::LlmRequest; +use serde_json::{Value, json}; + +use crate::configuration::BOOTSTRAP_CLIENT_TOKEN_HEADER; +use crate::error::CliError; +use crate::sessions::LlmGatewayStart; + +use super::response::observable_headers; +use super::routes::{ + ProviderRoute, gateway_identifier, gateway_session_id, gateway_subagent_id, + gateway_upstream_url_override, +}; + +pub(super) struct PreparedGatewayRequest { + pub(super) method: Method, + pub(super) headers: HeaderMap, + pub(super) path: String, + pub(super) provider: ProviderRoute, + pub(super) upstream_url: String, + pub(super) body_bytes: Bytes, + pub(super) request_json: Value, + pub(super) streaming: bool, + pub(super) allow_environment_provider_auth: bool, +} + +pub(super) async fn prepare_gateway_request( + config: &crate::configuration::GatewayConfig, + request: Request, + allow_environment_provider_auth: bool, +) -> Result { + let (mut parts, body) = request.into_parts(); + parts.headers.remove(BOOTSTRAP_CLIENT_TOKEN_HEADER); + let provider = ProviderRoute::from_path(parts.uri.path()).ok_or_else(|| { + CliError::InvalidPayload(format!("unsupported gateway path {}", parts.uri.path())) + })?; + let body_bytes = axum::body::to_bytes(body, config.max_passthrough_body_bytes) + .await + .map_err(passthrough_body_error)?; + let request_json = serde_json::from_slice::(&body_bytes).unwrap_or(Value::Null); + let path_and_query = parts + .uri + .path_and_query() + .map(|path| path.as_str()) + .unwrap_or(parts.uri.path()); + let upstream_url = gateway_upstream_url_override( + provider, + &parts.headers, + path_and_query, + allow_environment_provider_auth, + ) + .unwrap_or_else(|| provider.upstream_url(config, path_and_query)); + let streaming = request_json + .get("stream") + .and_then(Value::as_bool) + .unwrap_or(false); + Ok(PreparedGatewayRequest { + method: parts.method, + headers: parts.headers, + path: parts.uri.path().to_string(), + provider, + upstream_url, + body_bytes, + request_json, + streaming, + allow_environment_provider_auth, + }) +} + +fn passthrough_body_error(error: axum::Error) -> CliError { + if error.source().is_some_and(|source| { + source.is::() + || source + .source() + .is_some_and(|source| source.is::()) + }) { + CliError::PayloadTooLarge(error.to_string()) + } else { + CliError::InvalidPayload(error.to_string()) + } +} + +pub(super) fn build_llm_gateway_start(request: &PreparedGatewayRequest) -> LlmGatewayStart { + LlmGatewayStart { + session_id: gateway_session_id(&request.headers, &request.request_json, request.provider), + provider: request.provider.name().to_string(), + model_name: request + .request_json + .get("model") + .and_then(Value::as_str) + .map(ToOwned::to_owned), + subagent_id: gateway_subagent_id(&request.headers, &request.request_json, request.provider), + conversation_id: gateway_identifier( + &request.headers, + &request.request_json, + "x-nemo-relay-conversation-id", + &[ + &["conversation_id"], + &["conversationId"], + &["conversation", "id"], + ], + ), + generation_id: gateway_identifier( + &request.headers, + &request.request_json, + "x-nemo-relay-generation-id", + &[&["generation_id"], &["generationId"], &["generation", "id"]], + ), + request_id: gateway_identifier( + &request.headers, + &request.request_json, + "x-nemo-relay-request-id", + &[ + &["request_id"], + &["requestId"], + &["request", "id"], + &["metadata", "request_id"], + ], + ) + .or_else(|| crate::configuration::header_string(&request.headers, "x-request-id")), + request: LlmRequest { + headers: observable_headers(&request.headers), + content: request.request_json.clone(), + }, + streaming: request.streaming, + metadata: json!({ "gateway_path": request.path }), + } +} diff --git a/crates/cli/src/gateway/response.rs b/crates/cli/src/gateway/response.rs new file mode 100644 index 000000000..29f1e11e3 --- /dev/null +++ b/crates/cli/src/gateway/response.rs @@ -0,0 +1,112 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Observable header policy and downstream response construction. + +use axum::body::Body; +use axum::http::{HeaderMap, HeaderName, Response, StatusCode}; +use serde_json::{Map, Value, json}; + +use crate::configuration::BOOTSTRAP_CLIENT_TOKEN_HEADER; +use crate::error::CliError; + +pub(super) fn observable_headers(headers: &HeaderMap) -> Map { + let mut output = Map::new(); + for (name, value) in headers { + if should_record_header(name, headers) + && let Ok(value) = value.to_str() + { + output.insert(name.as_str().to_string(), json!(value)); + } + } + output +} + +// Copies upstream response headers except hop-by-hop transport headers that Axum/hyper must manage +// for the downstream connection. Multiple values are appended to preserve provider behavior. +// Content-Length is also dropped because the gateway re-encodes streaming responses and the +// upstream-reported length will not match the bytes the client sees. +pub(super) fn response_headers(headers: &HeaderMap) -> HeaderMap { + let mut output = HeaderMap::new(); + for (name, value) in headers { + if !is_hop_by_hop(name) + && !named_by_connection_header(name, headers) + && name != http::header::CONTENT_LENGTH + { + output.append(name.clone(), value.clone()); + } + } + output +} + +// Reconstructs an Axum response from upstream status, filtered headers, and the selected body. All +// builder errors are converted into gateway HTTP errors rather than panics. +pub(super) fn build_response( + status: StatusCode, + headers: HeaderMap, + body: Body, +) -> Result, CliError> { + let mut builder = Response::builder().status(status); + for (name, value) in &headers { + builder = builder.header(name, value); + } + Ok(builder.body(body)?) +} + +// Allows provider request headers through unless they are transport-owned or must be recalculated +// for the forwarded body. Host and content length are intentionally excluded because reqwest sets +// them for the upstream connection. +pub(super) fn should_forward_request_header(name: &HeaderName, headers: &HeaderMap) -> bool { + !is_hop_by_hop(name) + && !named_by_connection_header(name, headers) + && name != http::header::HOST + && name != http::header::CONTENT_LENGTH + && name.as_str() != BOOTSTRAP_CLIENT_TOKEN_HEADER + // Strip Accept-Encoding so upstreams return identity-encoded bodies; otherwise the + // observability capture (`output.value` on LLM spans, ATIF trajectory bodies) records + // gzip/br/zstd bytes that downstream consumers can't read. Bandwidth cost is paid only + // on the gateway-upstream hop. The client never asked for the encoding it would have + // received from upstream, so its decoders never trigger. + && name != http::header::ACCEPT_ENCODING +} + +// Allows headers into observability metadata only after removing credentials and provider API keys. +// The forwarding filter runs first so hop-by-hop transport headers are also excluded from recorded +// LLM request attributes. The credential blocklist covers the four canonical cases we see in +// practice: `Authorization` (most providers), `Cookie` (session credentials), `x-api-key` (OpenAI +// SDK and similar), `anthropic-api-key` (Anthropic), and the generic `api-key` alias used by some +// providers/proxies (e.g., Azure OpenAI). `HeaderName::as_str()` already returns the canonical +// lowercase form so string comparisons are case-insensitive by construction. +pub(super) fn should_record_header(name: &HeaderName, headers: &HeaderMap) -> bool { + should_forward_request_header(name, headers) + && name != http::header::AUTHORIZATION + && name != http::header::COOKIE + && name.as_str() != "x-api-key" + && name.as_str() != "api-key" + && name.as_str() != "anthropic-api-key" +} + +fn named_by_connection_header(name: &HeaderName, headers: &HeaderMap) -> bool { + headers + .get_all(http::header::CONNECTION) + .iter() + .filter_map(|value| value.to_str().ok()) + .flat_map(|value| value.split(',')) + .any(|token| token.trim().eq_ignore_ascii_case(name.as_str())) +} + +// Identifies headers that describe a single transport hop and therefore must not be proxied across +// the client-gateway-upstream boundary. +pub(super) fn is_hop_by_hop(name: &HeaderName) -> bool { + matches!( + name.as_str(), + "connection" + | "keep-alive" + | "proxy-authenticate" + | "proxy-authorization" + | "te" + | "trailer" + | "transfer-encoding" + | "upgrade" + ) +} diff --git a/crates/cli/src/gateway/routes.rs b/crates/cli/src/gateway/routes.rs new file mode 100644 index 000000000..08fb44c70 --- /dev/null +++ b/crates/cli/src/gateway/routes.rs @@ -0,0 +1,235 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Provider route classification and agent alignment policy. + +use super::*; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum ProviderRoute { + OpenAiResponses, + OpenAiChatCompletions, + OpenAiModels, + AnthropicMessages, + AnthropicCountTokens, +} + +#[derive(Clone, Copy)] +pub(super) struct ProviderForwarding { + pub(super) route: ProviderRoute, + pub(super) allow_environment_provider_auth: bool, +} + +impl ProviderForwarding { + pub(super) fn new(route: ProviderRoute, allow_environment_provider_auth: bool) -> Self { + Self { + route, + allow_environment_provider_auth, + } + } +} + +impl ProviderRoute { + // Maps public gateway paths to known upstream provider routes. Unsupported paths return `None` + // so the caller can fail as a bad hook/gateway payload instead of constructing arbitrary URLs. + pub(super) fn from_path(path: &str) -> Option { + match path { + "/responses" => Some(Self::OpenAiResponses), + "/v1/responses" => Some(Self::OpenAiResponses), + "/chat/completions" => Some(Self::OpenAiChatCompletions), + "/v1/chat/completions" => Some(Self::OpenAiChatCompletions), + "/models" => Some(Self::OpenAiModels), + "/v1/models" => Some(Self::OpenAiModels), + "/v1/messages" => Some(Self::AnthropicMessages), + "/v1/messages/count_tokens" => Some(Self::AnthropicCountTokens), + _ => None, + } + } + + pub(super) fn from_dispatch_override(value: &str) -> Option { + match value { + "openai_chat" + | "openai_chat_completions" + | "openai.chat_completions" + | "/v1/chat/completions" => Some(Self::OpenAiChatCompletions), + "openai_responses" | "openai.responses" | "/v1/responses" => { + Some(Self::OpenAiResponses) + } + "openai_models" | "openai.models" | "/models" | "/v1/models" => { + Some(Self::OpenAiModels) + } + "anthropic_messages" | "anthropic.messages" | "/v1/messages" => { + Some(Self::AnthropicMessages) + } + "anthropic_count_tokens" | "anthropic.count_tokens" | "/v1/messages/count_tokens" => { + Some(Self::AnthropicCountTokens) + } + _ => None, + } + } + + pub(super) const fn provider_surface(self) -> Option { + match self { + Self::OpenAiResponses => Some(ProviderSurface::OpenAIResponses), + Self::OpenAiChatCompletions => Some(ProviderSurface::OpenAIChat), + Self::AnthropicMessages => Some(ProviderSurface::AnthropicMessages), + Self::AnthropicCountTokens | Self::OpenAiModels => None, + } + } + + // Returns the provider route name recorded on managed LLM events. These names split OpenAI API + // variants because their request/response schemas differ even when they share a base URL, and + // they double as codec hints for ambiguous provider request shapes. + pub(super) const fn name(self) -> &'static str { + self.alignment_route().name() + } + + // Builds the upstream URL by combining the configured provider base with the original path and + // query string. Trailing slashes are stripped from the base to avoid double-slash variants in + // configured enterprise or local proxy endpoints. + pub(super) fn upstream_url( + self, + config: &crate::configuration::GatewayConfig, + path_and_query: &str, + ) -> String { + let base = match self { + Self::OpenAiResponses | Self::OpenAiChatCompletions | Self::OpenAiModels => { + config.openai_base_url.as_str() + } + Self::AnthropicMessages | Self::AnthropicCountTokens => { + config.anthropic_base_url.as_str() + } + }; + self.upstream_url_with_base(base, path_and_query) + } + + // Like `upstream_url` but with an explicit base URL. This keeps OpenAI `/v1` normalization in + // one place for configured public, enterprise, or local proxy bases. + pub(super) fn upstream_url_with_base(self, base: &str, path_and_query: &str) -> String { + let base = base.trim_end_matches('/'); + let path_and_query = match self { + Self::OpenAiResponses | Self::OpenAiChatCompletions | Self::OpenAiModels => { + normalize_openai_path_for_base(base, path_and_query) + } + _ => path_and_query.to_string(), + }; + format!("{base}{path_and_query}") + } + + // Narrows gateway routing to the smaller taxonomy used by trace alignment. Keeping this + // conversion here prevents provider-specific alignment code from depending on gateway URL + // routing internals. + pub(super) const fn alignment_route(self) -> GatewayRouteKind { + match self { + Self::OpenAiResponses => GatewayRouteKind::OpenAiResponses, + Self::OpenAiChatCompletions => GatewayRouteKind::OpenAiChatCompletions, + Self::OpenAiModels => GatewayRouteKind::OpenAiModels, + Self::AnthropicMessages => GatewayRouteKind::AnthropicMessages, + Self::AnthropicCountTokens => GatewayRouteKind::AnthropicCountTokens, + } + } +} + +pub(super) fn normalize_openai_path_for_base(base: &str, path_and_query: &str) -> String { + match (base.ends_with("/v1"), path_and_query.starts_with("/v1/")) { + (true, true) => path_and_query + .strip_prefix("/v1") + .expect("path was checked to start with /v1") + .to_string(), + (false, false) => format!("/v1{path_and_query}"), + _ => path_and_query.to_string(), + } +} + +// Gives alignment adapters a chance to choose an agent-native upstream before default provider +// routing runs. Today this supports Codex ChatGPT auth; future harness fallbacks should stay in +// alignment rather than adding provider-shaped checks here. +pub(super) fn gateway_upstream_url_override( + route: ProviderRoute, + headers: &HeaderMap, + path_and_query: &str, + allow_environment_provider_auth: bool, +) -> Option { + gateway_upstream_url_override_with_openai_key_state( + route, + headers, + path_and_query, + allow_environment_provider_auth && env_var_is_nonempty("OPENAI_API_KEY"), + ) +} + +pub(super) fn gateway_upstream_url_override_with_openai_key_state( + route: ProviderRoute, + headers: &HeaderMap, + path_and_query: &str, + has_openai_replacement_key: bool, +) -> Option { + alignment::gateway_upstream_url_override( + headers, + route.alignment_route(), + path_and_query, + has_openai_replacement_key, + ) +} + +// Lets alignment adapters strip agent-native credentials only when the gateway can replace them +// with standard provider API keys. Whitespace-only env vars are treated as missing because +// forwarding an empty bearer value only replaces one authentication failure with another. +pub(super) fn strip_replaceable_agent_auth_headers( + headers: &HeaderMap, + route: ProviderRoute, + allow_environment_provider_auth: bool, +) -> HeaderMap { + strip_replaceable_agent_auth_headers_with_openai_key_state( + headers, + route, + allow_environment_provider_auth && env_var_is_nonempty("OPENAI_API_KEY"), + ) +} + +pub(super) fn strip_replaceable_agent_auth_headers_with_openai_key_state( + headers: &HeaderMap, + route: ProviderRoute, + has_openai_replacement_key: bool, +) -> HeaderMap { + alignment::gateway_forward_headers(headers, route.alignment_route(), has_openai_replacement_key) +} + +pub(super) fn env_var_is_nonempty(name: &str) -> bool { + std::env::var(name) + .ok() + .filter(|value| !value.trim().is_empty()) + .is_some() +} + +// Delegates provider-specific session fallbacks to `alignment` so request construction stays +// generic and each coding-agent quirk has one documented adapter. +pub(super) fn gateway_session_id( + headers: &HeaderMap, + body: &Value, + route: ProviderRoute, +) -> Option { + alignment::gateway_session_id(headers, body, route.alignment_route()) +} + +pub(super) fn gateway_subagent_id( + headers: &HeaderMap, + body: &Value, + route: ProviderRoute, +) -> Option { + alignment::gateway_subagent_id(headers, body, route.alignment_route()) +} + +// Keeps the gateway-facing helper local for tests while the generic extraction pattern lives in +// `alignment`. +pub(super) fn gateway_identifier( + headers: &HeaderMap, + body: &Value, + header_name: &'static str, + body_paths: &[&[&str]], +) -> Option { + alignment::gateway_identifier(headers, body, header_name, body_paths) +} + +// Copies only non-sensitive, forwardable request headers into LLM request metadata. This preserves +// correlation headers while excluding credentials and hop-by-hop transport details. diff --git a/crates/cli/src/gateway/tls.rs b/crates/cli/src/gateway/tls.rs new file mode 100644 index 000000000..850fad57a --- /dev/null +++ b/crates/cli/src/gateway/tls.rs @@ -0,0 +1,89 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Pinned per-user TLS identity for authenticated bootstrap hook delivery. + +use std::sync::Arc; + +use rustls::pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer}; +use serde::{Deserialize, Serialize}; + +const IDENTITY_FILE: &str = "hook-tls-identity.json"; + +#[derive(Deserialize, Serialize)] +struct IdentityRecord { + certificate_der: Vec, + private_key_der: Vec, +} + +pub(crate) struct RelayTlsIdentity { + record: IdentityRecord, +} + +impl RelayTlsIdentity { + pub(crate) fn load_or_create() -> Result { + let path = identity_path()?; + if path.exists() { + return Self::load(); + } + let certified = rcgen::generate_simple_self_signed(vec!["localhost".into()]) + .map_err(|error| format!("failed to generate Relay TLS identity: {error}"))?; + let record = IdentityRecord { + certificate_der: certified.cert.der().to_vec(), + private_key_der: certified.key_pair.serialize_der(), + }; + let bytes = serde_json::to_vec(&record) + .map_err(|error| format!("failed to encode Relay TLS identity: {error}"))?; + crate::filesystem::atomic_write_private(&path, &bytes)?; + Ok(Self { record }) + } + + pub(crate) fn load() -> Result { + let path = identity_path()?; + let bytes = crate::filesystem::bounded::read_bounded_regular_file( + &path, + "Relay hook TLS identity", + )?; + let record = serde_json::from_slice::(&bytes) + .map_err(|error| format!("invalid Relay TLS identity {}: {error}", path.display()))?; + if record.certificate_der.is_empty() || record.private_key_der.is_empty() { + return Err(format!( + "Relay TLS identity {} is incomplete", + path.display() + )); + } + Ok(Self { record }) + } + + pub(crate) fn server_config(&self) -> Result, String> { + let certificate = CertificateDer::from(self.record.certificate_der.clone()); + let key = PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from( + self.record.private_key_der.clone(), + )); + rustls::ServerConfig::builder() + .with_no_client_auth() + .with_single_cert(vec![certificate], key) + .map(Arc::new) + .map_err(|error| format!("invalid Relay TLS server identity: {error}")) + } + + pub(crate) fn client_config(&self) -> Result, String> { + let mut roots = rustls::RootCertStore::empty(); + roots + .add(CertificateDer::from(self.record.certificate_der.clone())) + .map_err(|error| format!("invalid pinned Relay TLS certificate: {error}"))?; + Ok(Arc::new( + rustls::ClientConfig::builder() + .with_root_certificates(roots) + .with_no_client_auth(), + )) + } +} + +fn identity_path() -> Result { + Ok(crate::bootstrap::state::state_dir()?.join(IDENTITY_FILE)) +} + +#[cfg(test)] +#[path = "../../tests/coverage/shared/gateway_tls_tests.rs"] +mod tests; diff --git a/crates/cli/src/hooks/delivery.rs b/crates/cli/src/hooks/delivery.rs new file mode 100644 index 000000000..d74939fb3 --- /dev/null +++ b/crates/cli/src/hooks/delivery.rs @@ -0,0 +1,273 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use std::io::Read; +use std::time::Duration; + +use reqwest::header::{CONTENT_TYPE, HeaderMap, HeaderName, HeaderValue}; +use serde_json::Value; + +use crate::error::CliError; +use crate::installation::generation::InstallGeneration; + +use super::destination::{ + HookGatewayLifecycle, hook_destination, recovery_plan, transparent_gateway_spec, + transparent_run_active, wait_for_existing_gateway, +}; +use super::response::MAX_HOOK_RESPONSE_BYTES; +use super::response::{handle_hook_forward_response, handle_verified_hook_forward_response}; +use super::{GatewayMode, HookForwardRequest}; + +const HOOK_FORWARD_TIMEOUT: Duration = Duration::from_secs(2); + +pub(crate) async fn hook_forward(command: HookForwardRequest) -> Result<(), CliError> { + // A transparent wrapper can coexist with any installed Relay plugin. Its process marker makes + // persistent plugin hooks inert, while only the wrapper-owned command carries + // `--transparent-run` and forwards to the process-private gateway. This avoids rewriting host + // plugin settings and works for both installer and source-marketplace plugin identities. + if transparent_run_active() && !command.transparent_run { + return Ok(()); + } + validate_optional_json("session metadata", command.session_metadata.as_deref())?; + let fail_closed = + command.fail_closed || std::env::var("NEMO_RELAY_FAIL_CLOSED").ok().as_deref() == Some("1"); + let destination = hook_destination(&command); + let persistent = match (destination.lifecycle != HookGatewayLifecycle::Transparent) + .then(|| recovery_plan(&destination.gateway_url)) + .transpose() + { + Ok(persistent) => persistent, + Err(error) => return handle_hook_error(error, fail_closed), + }; + let transparent_gateway = match command + .transparent_run + .then(|| transparent_gateway_spec(&destination.gateway_url)) + .transpose() + { + Ok(gateway) => gateway, + Err(error) => return handle_hook_error(error, fail_closed), + }; + let _generation_guard = if destination.lifecycle == HookGatewayLifecycle::Existing + && !command.forward_only + { + let install_host = command.agent.install_arg(); + let Some(generation_file) = command.generation_file.clone() else { + return handle_hook_error( + CliError::Launch(format!( + "persistent {} hook is missing its install-generation fence; run `nemo-relay install {install_host} --force`", + command.agent.label() + )), + fail_closed, + ); + }; + let Some(generation_token) = command.generation_token.as_deref() else { + return handle_hook_error( + CliError::Launch(format!( + "persistent {} hook is missing its expected install-generation identity; run `nemo-relay install {install_host} --force`", + command.agent.label() + )), + fail_closed, + ); + }; + match InstallGeneration::capture_guarded_expected(generation_file, generation_token) { + Ok((_generation, guard)) => Some(guard), + Err(error) => return handle_hook_error(CliError::Launch(error), fail_closed), + } + } else { + None + }; + let input = match read_hook_payload(persistent.as_ref().map_or( + crate::configuration::DEFAULT_MAX_HOOK_PAYLOAD_BYTES, + |launch| launch.max_hook_payload_bytes, + )) { + Ok(input) => input, + Err(error) => return handle_hook_error(error, fail_closed), + }; + if destination.lifecycle == HookGatewayLifecycle::Existing { + let gateway = persistent + .as_ref() + .expect("existing persistent destinations resolve a gateway") + .gateway + .clone(); + if let Err(error) = + wait_for_existing_gateway(gateway, destination.gateway_url.clone()).await + { + return handle_hook_error(error, fail_closed); + } + } + let verified_gateway = persistent + .as_ref() + .map(|launch| &launch.gateway) + .or(transparent_gateway.as_ref()); + if let Some(gateway) = verified_gateway { + let response = match send_verified_hook_forward_request( + &command, + gateway, + &destination.gateway_url, + input, + ) + .await + { + Ok(response) => response, + Err(error) => return handle_hook_error(error, fail_closed), + }; + return handle_verified_hook_forward_response(response, fail_closed); + } + + let url = format!( + "{}{}", + destination.gateway_url.trim_end_matches('/'), + command.agent.hook_path() + ); + let response = match send_hook_forward_request(&command, &url, input).await { + Ok(response) => response, + Err(error) => return handle_hook_error(error, fail_closed), + }; + handle_hook_forward_response(response, fail_closed).await +} + +fn handle_hook_error(error: CliError, fail_closed: bool) -> Result<(), CliError> { + eprintln!("nemo-relay hook forward failed: {error}"); + if fail_closed { Err(error) } else { Ok(()) } +} + +// Reads the native hook payload from stdin and normalizes empty payloads to JSON object syntax. +// This keeps hook commands observable even for agents or events that invoke hooks without input. +fn read_hook_payload(limit: usize) -> Result { + read_hook_payload_from(std::io::stdin(), limit) +} + +pub(crate) fn read_hook_payload_from(reader: impl Read, limit: usize) -> Result { + let mut bytes = Vec::new(); + reader + .take(limit.saturating_add(1) as u64) + .read_to_end(&mut bytes)?; + if bytes.len() > limit { + return Err(CliError::Install(format!( + "hook payload exceeds the {limit}-byte limit" + ))); + } + let input = String::from_utf8(bytes) + .map_err(|error| CliError::Install(format!("hook payload is not valid UTF-8: {error}")))?; + if input.trim().is_empty() { + Ok("{}".to_string()) + } else { + Ok(input) + } +} + +pub(crate) async fn send_verified_hook_forward_request( + command: &HookForwardRequest, + gateway: &crate::bootstrap::GatewaySpec, + gateway_url: &str, + input: String, +) -> Result< + Result, + CliError, +> { + let headers = gateway_headers( + command.profile.as_deref(), + command.session_metadata.as_deref(), + command.gateway_mode, + )? + .iter() + .map(|(name, value)| { + value + .to_str() + .map(|value| (name.as_str().to_string(), value.to_string())) + .map_err(|error| { + CliError::Install(format!( + "hook header {name} is not valid HTTP text: {error}" + )) + }) + }) + .collect::, _>>()?; + let gateway = gateway.clone(); + let gateway_url = gateway_url.to_string(); + let path = command.agent.hook_path().to_string(); + tokio::task::spawn_blocking(move || { + gateway.post_verified( + &gateway_url, + &path, + &headers, + input.as_bytes(), + HOOK_FORWARD_TIMEOUT, + MAX_HOOK_RESPONSE_BYTES, + ) + }) + .await + .map_err(|error| CliError::Launch(format!("verified hook request task failed: {error}"))) +} + +// Sends the hook payload with gateway-specific headers translated from CLI flags. The reqwest +// transport result is returned separately so response handling can preserve fail-open semantics. +async fn send_hook_forward_request( + command: &HookForwardRequest, + url: &str, + input: String, +) -> Result, CliError> { + Ok(reqwest::Client::builder() + .no_proxy() + .redirect(reqwest::redirect::Policy::none()) + .timeout(HOOK_FORWARD_TIMEOUT) + .build()? + .post(url) + .headers(gateway_headers( + command.profile.as_deref(), + command.session_metadata.as_deref(), + command.gateway_mode, + )?) + .header(CONTENT_TYPE, "application/json") + .body(input) + .send() + .await) +} + +// Handles hook delivery results without changing agent control flow unless `--fail-closed` was +// requested. Successful non-empty endpoint bodies are printed verbatim for the invoking hook API. +fn validate_optional_json(name: &str, value: Option<&str>) -> Result<(), CliError> { + if let Some(value) = value { + serde_json::from_str::(value) + .map_err(|error| CliError::Install(format!("invalid {name}: {error}")))?; + } + Ok(()) +} + +// Converts optional session/export/gateway settings into gateway headers for hook-forward. Each +// absent value is omitted so the server can fall back to file, environment, or default config. +pub(crate) fn gateway_headers( + profile: Option<&str>, + session_metadata: Option<&str>, + gateway_mode: Option, +) -> Result { + let mut headers = HeaderMap::new(); + insert_header(&mut headers, "x-nemo-relay-config-profile", profile)?; + insert_header( + &mut headers, + "x-nemo-relay-session-metadata", + session_metadata, + )?; + insert_header( + &mut headers, + "x-nemo-relay-gateway-mode", + gateway_mode.map(GatewayMode::as_arg), + )?; + Ok(headers) +} + +// Inserts one optional header after validating it is legal HTTP header text. Invalid values are +// reported as installer errors because they came from generated or user-provided hook options. +pub(crate) fn insert_header( + headers: &mut HeaderMap, + name: &'static str, + value: Option<&str>, +) -> Result<(), CliError> { + if let Some(value) = value { + headers.insert( + HeaderName::from_static(name), + HeaderValue::from_str(value) + .map_err(|error| CliError::Install(format!("invalid header {name}: {error}")))?, + ); + } + Ok(()) +} diff --git a/crates/cli/src/hooks/destination.rs b/crates/cli/src/hooks/destination.rs new file mode 100644 index 000000000..4b27272b9 --- /dev/null +++ b/crates/cli/src/hooks/destination.rs @@ -0,0 +1,119 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use std::time::{Duration, Instant}; + +use crate::configuration::{GATEWAY_URL_ENV, TRANSPARENT_RUN_ENV}; +use crate::error::CliError; + +use super::HookForwardRequest; + +const HOOK_GATEWAY_RETRY_TIMEOUT: Duration = Duration::from_secs(20); + +pub(crate) struct HookDestination { + pub(crate) gateway_url: String, + pub(crate) lifecycle: HookGatewayLifecycle, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum HookGatewayLifecycle { + /// A transparent run owns the dynamic gateway and passes its URL through the environment. + Transparent, + /// Persistent hooks use the authenticated gateway started and maintained by MCP. + Existing, +} + +// Installed hooks use the shared fixed gateway that MCP owns. Transparent runs set the dynamic +// environment URL and already own that gateway's lifecycle. +pub(super) fn hook_destination(command: &HookForwardRequest) -> HookDestination { + resolve_hook_destination( + command.gateway_url.clone(), + std::env::var(GATEWAY_URL_ENV).ok(), + command.forward_only, + command.transparent_run, + ) +} + +pub(super) fn transparent_run_active() -> bool { + std::env::var(TRANSPARENT_RUN_ENV).ok().as_deref() == Some("1") +} + +pub(crate) fn resolve_hook_destination( + command_url: Option, + environment_url: Option, + forward_only: bool, + transparent_run: bool, +) -> HookDestination { + if transparent_run { + return HookDestination { + gateway_url: command_url + .or(environment_url) + .unwrap_or_else(|| crate::bootstrap::DEFAULT_URL.into()), + lifecycle: HookGatewayLifecycle::Transparent, + }; + } + if forward_only { + return HookDestination { + gateway_url: command_url.unwrap_or_else(|| crate::bootstrap::DEFAULT_URL.into()), + lifecycle: HookGatewayLifecycle::Existing, + }; + } + if let Some(gateway_url) = command_url { + return HookDestination { + gateway_url, + lifecycle: HookGatewayLifecycle::Existing, + }; + } + if let Some(gateway_url) = environment_url { + return HookDestination { + gateway_url, + lifecycle: HookGatewayLifecycle::Transparent, + }; + } + HookDestination { + gateway_url: crate::bootstrap::DEFAULT_URL.into(), + lifecycle: HookGatewayLifecycle::Existing, + } +} + +pub(super) async fn wait_for_existing_gateway( + gateway: crate::bootstrap::GatewaySpec, + gateway_url: String, +) -> Result<(), CliError> { + tokio::task::spawn_blocking(move || { + let deadline = Instant::now() + HOOK_GATEWAY_RETRY_TIMEOUT; + loop { + match gateway.existing_healthy_instance(&gateway_url) { + Ok(Some(_instance_id)) => return Ok(()), + Ok(None) if Instant::now() < deadline => { + std::thread::sleep(Duration::from_millis(50)); + } + Ok(None) => { + return Err(format!( + "no compatible Relay gateway became ready at {gateway_url}; ensure the host started `nemo-relay mcp`" + )); + } + Err(error) => return Err(error), + } + } + }) + .await + .map_err(|error| CliError::Launch(format!("hook gateway verification task failed: {error}")))? + .map_err(CliError::Launch) +} + +pub(super) fn recovery_plan( + gateway_url: &str, +) -> Result { + let bind = crate::gateway::client::loopback_bind(gateway_url).map_err(CliError::Install)?; + crate::bootstrap::resolve_plugin_gateway(&Default::default(), bind) +} + +pub(crate) fn transparent_gateway_spec( + gateway_url: &str, +) -> Result { + let bind = crate::gateway::client::loopback_bind(gateway_url).map_err(CliError::Install)?; + Ok(crate::bootstrap::GatewaySpec::new(bind).with_fingerprint( + crate::configuration::transparent_gateway_fingerprint(gateway_url), + )) +} diff --git a/crates/cli/src/hooks/encoding.rs b/crates/cli/src/hooks/encoding.rs new file mode 100644 index 000000000..1ac762c65 --- /dev/null +++ b/crates/cli/src/hooks/encoding.rs @@ -0,0 +1,356 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Hook definition and portable command encoding. + +use std::path::Path; + +use serde_json::{Value, json}; + +use crate::agents::CodingAgent; + +#[cfg(any(windows, test))] +use base64::Engine; + +pub(crate) fn generated_hooks(agent: CodingAgent, command: &str) -> Value { + if agent.uses_direct_hook_entries() { + direct_hooks(agent.hook_events(), command) + } else { + grouped_hooks(agent.hook_events(), command) + } +} + +/// Canonical persistent hook command used by every supported host. +pub(crate) fn persistent_hook_forward_command( + relay: &Path, + agent: CodingAgent, + generation_file: &Path, + generation_token: &str, +) -> Result { + hook_command( + relay, + &persistent_hook_arguments(agent, generation_file, generation_token), + ) +} + +/// Canonical transparent hook command. It embeds the process-private dynamic gateway so hook hosts +/// that filter inherited environment variables cannot redirect delivery to the fixed endpoint. +pub(crate) fn transparent_hook_forward_command( + relay: &Path, + agent: CodingAgent, + gateway_url: &str, +) -> Result { + hook_command(relay, &transparent_hook_arguments(agent, gateway_url)) +} + +#[cfg(test)] +pub(crate) fn transparent_hook_forward_command_for_platform( + relay: &Path, + agent: CodingAgent, + gateway_url: &str, + windows: bool, +) -> String { + hook_command_for_platform( + relay, + &transparent_hook_arguments(agent, gateway_url), + windows, + ) +} + +#[cfg(test)] +pub(crate) fn persistent_hook_forward_command_for_platform( + relay: &Path, + agent: CodingAgent, + generation_file: &Path, + generation_token: &str, + windows: bool, +) -> String { + hook_command_for_platform( + relay, + &persistent_hook_arguments(agent, generation_file, generation_token), + windows, + ) +} + +pub(super) fn transparent_hook_arguments(agent: CodingAgent, gateway_url: &str) -> Vec { + vec![ + "hook-forward".into(), + agent.as_arg().into(), + "--gateway-url".into(), + gateway_url.into(), + "--transparent-run".into(), + ] +} + +pub(super) fn persistent_hook_arguments( + agent: CodingAgent, + generation_file: &Path, + generation_token: &str, +) -> Vec { + vec![ + "hook-forward".into(), + agent.as_arg().into(), + "--gateway-url".into(), + crate::bootstrap::DEFAULT_URL.into(), + "--generation-file".into(), + generation_file.display().to_string(), + "--generation-token".into(), + generation_token.into(), + ] +} + +pub(super) fn hook_command(relay: &Path, arguments: &[String]) -> Result { + #[cfg(windows)] + { + return encoded_windows_hook_command(&windows_powershell_launcher()?, relay, arguments); + } + #[cfg(not(windows))] + { + Ok(posix_hook_command(relay, arguments)) + } +} + +#[cfg(test)] +pub(super) fn hook_command_for_platform( + relay: &Path, + arguments: &[String], + windows: bool, +) -> String { + if windows { + return encoded_windows_hook_command( + "C:/Windows/System32/WindowsPowerShell/v1.0/powershell.exe", + relay, + arguments, + ) + .expect("test hook command must fit within the Windows command-line limit"); + } + posix_hook_command(relay, arguments) +} + +#[cfg(any(not(windows), test))] +pub(super) fn posix_hook_command(relay: &Path, arguments: &[String]) -> String { + std::iter::once(relay.display().to_string()) + .chain(arguments.iter().cloned()) + .map(|argument| crate::agents::shell_quote_arg_for_platform(&argument, false)) + .collect::>() + .join(" ") +} + +// `cmd.exe` accepts at most 8,191 characters. Leave room for `/C` and the executable path added +// by the hook host instead of generating a command that will be truncated at runtime. +#[cfg(any(windows, test))] +const MAX_WINDOWS_HOOK_COMMAND_UTF16_UNITS: usize = 8_000; + +/// Encode a native Relay invocation so Windows hook hosts can pass it through `cmd.exe /C` as one +/// argument without corrupting quotes in canonical paths. Windows PowerShell is part of the +/// supported Windows platform; it only launches the Rust binary and preserves its standard I/O. +#[cfg(any(windows, test))] +pub(crate) fn encoded_windows_hook_command( + powershell: &str, + relay: &Path, + arguments: &[String], +) -> Result { + const PREFIX: &str = "$ErrorActionPreference='Stop'; & "; + const SUFFIX: &str = "; if ($null -eq $LASTEXITCODE) { exit 1 }; exit $LASTEXITCODE"; + + let invocation = std::iter::once(relay.display().to_string()) + .chain(arguments.iter().cloned()) + .map(|argument| format!("'{}'", argument.replace('\'', "''"))) + .collect::>() + .join(" "); + let script = format!("{PREFIX}{invocation}{SUFFIX}"); + let bytes = script + .encode_utf16() + .flat_map(u16::to_le_bytes) + .collect::>(); + let encoded = base64::engine::general_purpose::STANDARD.encode(bytes); + let command = + format!("{powershell} -NoLogo -NoProfile -NonInteractive -EncodedCommand {encoded}"); + if command.encode_utf16().count() > MAX_WINDOWS_HOOK_COMMAND_UTF16_UNITS { + return Err(format!( + "generated Windows coding-agent hook command exceeds the {MAX_WINDOWS_HOOK_COMMAND_UTF16_UNITS}-character safety limit; shorten the Relay or plugin installation path" + )); + } + Ok(command) +} + +#[cfg(windows)] +pub(super) fn windows_powershell_launcher() -> Result { + let powershell = windows_powershell_path()?; + if !Path::new(&powershell).is_file() { + return Err(format!( + "trusted Windows PowerShell launcher is missing at {powershell}; install Windows PowerShell before configuring coding-agent hooks" + )); + } + Ok(powershell) +} + +#[cfg(windows)] +pub(crate) fn windows_powershell_path() -> Result { + use std::os::windows::ffi::OsStringExt; + use windows_sys::Win32::System::SystemInformation::GetSystemDirectoryW; + + let mut buffer = vec![0_u16; 260]; + let length = loop { + // SAFETY: `buffer` is writable for its declared length and remains live for the call. + let length = unsafe { GetSystemDirectoryW(buffer.as_mut_ptr(), buffer.len() as u32) }; + if length == 0 { + return Err(format!( + "failed to resolve the trusted Windows system directory: {}", + std::io::Error::last_os_error() + )); + } + if (length as usize) < buffer.len() { + break length as usize; + } + buffer.resize(length as usize + 1, 0); + }; + let system = std::path::PathBuf::from(std::ffi::OsString::from_wide(&buffer[..length])); + let powershell = system.join("WindowsPowerShell/v1.0/powershell.exe"); + let powershell = powershell + .into_os_string() + .into_string() + .map_err(|_| "trusted Windows PowerShell path is not valid Unicode".to_string())? + .replace('\\', "/"); + if !safe_windows_launcher_token(&powershell) { + return Err(format!( + "trusted Windows PowerShell path {powershell} contains characters that cannot be represented safely in coding-agent hook commands" + )); + } + Ok(powershell) +} + +#[cfg(any(windows, test))] +pub(super) fn safe_windows_launcher_token(launcher: &str) -> bool { + !launcher.is_empty() + && launcher.chars().all(|character| { + character.is_ascii_alphanumeric() || matches!(character, '/' | ':' | '.' | '_' | '-') + }) + && launcher + .to_ascii_lowercase() + .ends_with("/system32/windowspowershell/v1.0/powershell.exe") +} + +/// Decode only the exact PowerShell envelope emitted by [`encoded_windows_hook_command`]. +/// +/// Hermes uses this to migrate and replace Relay-owned hooks whose generation arguments change. +#[cfg(any(windows, test))] +pub(crate) fn decode_windows_hook_command(command: &str) -> Option> { + const COMMAND_SEPARATOR: &str = " -NoLogo -NoProfile -NonInteractive -EncodedCommand "; + const SCRIPT_PREFIX: &str = "$ErrorActionPreference='Stop'; & "; + const SCRIPT_SUFFIX: &str = "; if ($null -eq $LASTEXITCODE) { exit 1 }; exit $LASTEXITCODE"; + + if command.encode_utf16().count() > MAX_WINDOWS_HOOK_COMMAND_UTF16_UNITS { + return None; + } + let (launcher, encoded) = command.split_once(COMMAND_SEPARATOR)?; + if !safe_windows_launcher_token(launcher) { + return None; + } + #[cfg(windows)] + if !launcher.eq_ignore_ascii_case(&windows_powershell_path().ok()?) { + return None; + } + if encoded.is_empty() || encoded.chars().any(char::is_whitespace) { + return None; + } + let bytes = base64::engine::general_purpose::STANDARD + .decode(encoded) + .ok()?; + let pairs = bytes.chunks_exact(2); + if !pairs.remainder().is_empty() { + return None; + } + let script = String::from_utf16( + &pairs + .map(|pair| u16::from_le_bytes([pair[0], pair[1]])) + .collect::>(), + ) + .ok()?; + let invocation = script + .strip_prefix(SCRIPT_PREFIX)? + .strip_suffix(SCRIPT_SUFFIX)?; + parse_powershell_single_quoted_arguments(invocation) +} + +#[cfg(any(windows, test))] +pub(super) fn parse_powershell_single_quoted_arguments(mut raw: &str) -> Option> { + let mut arguments = Vec::new(); + while !raw.is_empty() { + raw = raw.strip_prefix('\'')?; + let mut argument = String::new(); + loop { + let quote = raw.find('\'')?; + argument.push_str(&raw[..quote]); + raw = &raw[quote + 1..]; + if let Some(rest) = raw.strip_prefix('\'') { + argument.push('\''); + raw = rest; + } else { + break; + } + } + arguments.push(argument); + if raw.is_empty() { + break; + } + raw = raw.strip_prefix(' ')?; + if raw.is_empty() { + return None; + } + } + (!arguments.is_empty()).then_some(arguments) +} + +pub(super) fn direct_hooks(events: &[&str], command: &str) -> Value { + let hooks: serde_json::Map = events + .iter() + .map(|event| { + ( + (*event).to_string(), + json!([{ + "command": command, + "timeout": 30 + }]), + ) + }) + .collect(); + json!({ "hooks": Value::Object(hooks) }) +} + +// Generates hook groups for Claude/Codex events and adds a wildcard matcher to tool events when +// the target agent requires matcher-scoped tool hooks. Non-tool events omit matchers so they fire +// for the full lifecycle. +pub(super) fn grouped_hooks(events: &[&str], command: &str) -> Value { + let hooks: serde_json::Map = events + .iter() + .map(|event| { + let mut group = serde_json::Map::new(); + if event_matches_tools(event) { + group.insert("matcher".into(), json!("*")); + } + group.insert( + "hooks".into(), + json!([{ + "type": "command", + "command": command, + "timeout": 30 + }]), + ); + ( + (*event).to_string(), + Value::Array(vec![Value::Object(group)]), + ) + }) + .collect(); + json!({ "hooks": Value::Object(hooks) }) +} + +// Identifies hook events that should receive wildcard tool matchers. The list includes current +// Claude/Codex spellings. +pub(crate) fn event_matches_tools(event: &str) -> bool { + matches!( + event, + "PreToolUse" | "PostToolUse" | "PostToolUseFailure" | "PermissionRequest" + ) +} diff --git a/crates/cli/src/hooks/merging.rs b/crates/cli/src/hooks/merging.rs new file mode 100644 index 000000000..c6ddef596 --- /dev/null +++ b/crates/cli/src/hooks/merging.rs @@ -0,0 +1,79 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Non-destructive lifecycle-hook configuration merging. + +use serde_json::{Value, json}; + +use crate::error::CliError; + +pub(crate) fn merge_hooks(existing: Value, generated: Value) -> Result { + let mut root = hook_config_root(existing)?; + let hooks = hooks_object_mut(&mut root)?; + let generated_hooks = generated_hooks_object(&generated)?; + for (event, groups) in generated_hooks { + merge_event_hook_groups(hooks, event, groups)?; + } + Ok(root) +} + +// Normalizes an existing hook config root. Missing files arrive as `Null`, valid JSON/YAML config +// roots remain objects, and other shapes are rejected before any write can occur. +pub(super) fn hook_config_root(existing: Value) -> Result { + match existing { + Value::Null => Ok(json!({})), + Value::Object(object) => Ok(Value::Object(object)), + _ => Err(CliError::Install( + "hook config must be a JSON object".into(), + )), + } +} + +// Returns the mutable `hooks` object from a config root, creating it when absent. A non-object +// `hooks` field is considered user config corruption and is not overwritten. +pub(super) fn hooks_object_mut( + root: &mut Value, +) -> Result<&mut serde_json::Map, CliError> { + root.as_object_mut() + .expect("root checked as object") + .entry("hooks") + .or_insert_with(|| json!({})) + .as_object_mut() + .ok_or_else(|| CliError::Install("hooks must be a JSON object".into())) +} + +// Validates generated hook shape before merging. Generated hooks are internal data, but checking +// here keeps test failures localized if an agent bundle generator regresses. +pub(super) fn generated_hooks_object( + generated: &Value, +) -> Result<&serde_json::Map, CliError> { + generated + .get("hooks") + .and_then(Value::as_object) + .ok_or_else(|| CliError::Install("generated hooks were malformed".into())) +} + +// Appends missing generated groups for one hook event. Equality comparison is exact so repeated +// writes are idempotent without trying to interpret vendor-specific hook group schemas. +pub(super) fn merge_event_hook_groups( + hooks: &mut serde_json::Map, + event: &str, + groups: &Value, +) -> Result<(), CliError> { + let groups = groups + .as_array() + .ok_or_else(|| CliError::Install("generated hook groups were malformed".into()))?; + let event_groups = hooks.entry(event.to_string()).or_insert_with(|| json!([])); + let event_groups = event_groups + .as_array_mut() + .ok_or_else(|| CliError::Install(format!("{event} hooks must be an array")))?; + for group in groups { + if !event_groups.iter().any(|existing| existing == group) { + event_groups.push(group.clone()); + } + } + Ok(()) +} + +// Validates optional JSON strings before they are embedded into hook-forward headers. Catches +// quoting/config mistakes at hook-fire time rather than after the request reaches the gateway. diff --git a/crates/cli/src/hooks/mod.rs b/crates/cli/src/hooks/mod.rs new file mode 100644 index 000000000..6389f3c2e --- /dev/null +++ b/crates/cli/src/hooks/mod.rs @@ -0,0 +1,44 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Hook delivery, command encoding, generated definitions, and configuration merging. + +mod delivery; +mod destination; +mod encoding; +mod merging; +mod response; +mod types; + +pub(crate) use delivery::hook_forward; +#[cfg(test)] +pub(crate) use delivery::send_verified_hook_forward_request; +#[cfg(test)] +pub(crate) use delivery::{gateway_headers, insert_header, read_hook_payload_from}; +#[cfg(test)] +pub(crate) use destination::{ + HookGatewayLifecycle, resolve_hook_destination, transparent_gateway_spec, +}; +#[cfg(any(windows, test))] +pub(crate) use encoding::decode_windows_hook_command; +#[cfg(all(test, windows))] +pub(crate) use encoding::windows_powershell_path; +#[cfg(test)] +pub(crate) use encoding::{ + encoded_windows_hook_command, event_matches_tools, + persistent_hook_forward_command_for_platform, transparent_hook_forward_command_for_platform, +}; +pub(crate) use encoding::{ + generated_hooks, persistent_hook_forward_command, transparent_hook_forward_command, +}; +pub(crate) use merging::merge_hooks; +#[cfg(test)] +pub(crate) use response::{handle_hook_forward_status, handle_verified_hook_forward_response}; +pub(crate) use types::{GatewayMode, HookForwardRequest}; + +#[cfg(test)] +use serde_json::json; + +#[cfg(test)] +#[path = "../../tests/coverage/shared/installer_tests.rs"] +mod tests; diff --git a/crates/cli/src/hooks/response.rs b/crates/cli/src/hooks/response.rs new file mode 100644 index 000000000..c06264fba --- /dev/null +++ b/crates/cli/src/hooks/response.rs @@ -0,0 +1,131 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Gateway response handling for forwarded lifecycle hooks. + +use futures_util::StreamExt; +use serde_json::Value; + +use crate::error::CliError; + +pub(super) const MAX_HOOK_RESPONSE_BYTES: usize = 1024 * 1024; + +pub(super) async fn handle_hook_forward_response( + response: Result, + fail_closed: bool, +) -> Result<(), CliError> { + match response { + Ok(response) => { + let status = response.status(); + let body = match read_hook_response(response).await { + Ok(body) => body, + Err(error) if fail_closed => return Err(error), + Err(error) => { + eprintln!("nemo-relay hook forward failed: {error}"); + return Ok(()); + } + }; + handle_hook_forward_status(status, body, fail_closed) + } + Err(error) => { + eprintln!("nemo-relay hook forward failed: {error}"); + if fail_closed { + Err(CliError::Upstream(error)) + } else { + Ok(()) + } + } + } +} + +pub(crate) fn handle_verified_hook_forward_response( + response: Result< + crate::gateway::client::VerifiedHttpResponse, + crate::gateway::client::VerifiedHttpError, + >, + fail_closed: bool, +) -> Result<(), CliError> { + match response { + Ok(response) => { + let status = match reqwest::StatusCode::from_u16(response.status) { + Ok(status) => status, + Err(error) => { + let message = format!("verified hook response had an invalid status: {error}"); + eprintln!("nemo-relay hook forward failed: {message}"); + return if fail_closed { + Err(CliError::Install(message)) + } else { + Ok(()) + }; + } + }; + handle_hook_forward_status( + status, + String::from_utf8_lossy(&response.body).into_owned(), + fail_closed, + ) + } + Err(error) => { + eprintln!("nemo-relay hook forward failed: {error}"); + if fail_closed { + Err(CliError::Install(format!( + "verified hook forward failed: {error}" + ))) + } else { + Ok(()) + } + } + } +} + +pub(crate) fn handle_hook_forward_status( + status: reqwest::StatusCode, + body: String, + fail_closed: bool, +) -> Result<(), CliError> { + if !status.is_success() { + if let Some(reason) = guardrail_rejection_reason(&body) { + return Err(CliError::GuardrailRejected(reason)); + } + eprintln!("nemo-relay hook forward failed with HTTP {status}"); + if fail_closed { + return Err(CliError::Install(format!( + "hook forward failed with HTTP {status}" + ))); + } + return Ok(()); + } + if !body.is_empty() { + println!("{body}"); + } + Ok(()) +} + +pub(super) async fn read_hook_response(response: reqwest::Response) -> Result { + let mut stream = response.bytes_stream(); + let mut body = Vec::new(); + while let Some(chunk) = stream.next().await { + let chunk = chunk?; + if body.len().saturating_add(chunk.len()) > MAX_HOOK_RESPONSE_BYTES { + return Err(CliError::Install(format!( + "hook forward response exceeds the {MAX_HOOK_RESPONSE_BYTES}-byte limit" + ))); + } + body.extend_from_slice(&chunk); + } + Ok(String::from_utf8_lossy(&body).into_owned()) +} + +pub(super) fn guardrail_rejection_reason(body: &str) -> Option { + let value: Value = serde_json::from_str(body).ok()?; + let error = value.get("error")?; + (error.get("type").and_then(Value::as_str) == Some("nemo_relay_guardrail_rejected")) + .then(|| { + error + .get("reason") + .and_then(Value::as_str) + .or_else(|| error.get("message").and_then(Value::as_str)) + .map(ToOwned::to_owned) + }) + .flatten() +} diff --git a/crates/cli/src/hooks/types.rs b/crates/cli/src/hooks/types.rs new file mode 100644 index 000000000..9959b309d --- /dev/null +++ b/crates/cli/src/hooks/types.rs @@ -0,0 +1,37 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use std::path::PathBuf; + +use crate::agents::CodingAgent; + +#[derive(Debug, Clone)] +pub(crate) struct HookForwardRequest { + pub(crate) agent: CodingAgent, + pub(crate) gateway_url: Option, + pub(crate) generation_file: Option, + pub(crate) generation_token: Option, + pub(crate) forward_only: bool, + pub(crate) transparent_run: bool, + pub(crate) profile: Option, + pub(crate) session_metadata: Option, + pub(crate) gateway_mode: Option, + pub(crate) fail_closed: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum GatewayMode { + HookOnly, + Passthrough, + Required, +} + +impl GatewayMode { + pub(crate) const fn as_arg(self) -> &'static str { + match self { + Self::HookOnly => "hook-only", + Self::Passthrough => "passthrough", + Self::Required => "required", + } + } +} diff --git a/crates/cli/src/installation/generation.rs b/crates/cli/src/installation/generation.rs new file mode 100644 index 000000000..822d548e9 --- /dev/null +++ b/crates/cli/src/installation/generation.rs @@ -0,0 +1,1224 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Private install-generation fencing for lifecycle-bound plugin MCP clients. + +use std::env; +use std::fs::{self, File, OpenOptions}; +use std::io::{Read, Seek, SeekFrom, Write}; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::thread; +use std::time::{Duration, Instant}; + +use base64::Engine; + +use crate::filesystem::{ + LockAttempt, atomic_write, try_lock_exclusive, try_lock_shared, unlock_file, +}; + +pub(crate) const GENERATION_FILE_ENV: &str = "NEMO_RELAY_MCP_GENERATION_FILE"; +pub(crate) const GENERATION_TOKEN_ENV: &str = "NEMO_RELAY_MCP_GENERATION"; +pub(crate) const GENERATION_FILE_NAME: &str = ".nemo-relay-generation"; +const MAX_GENERATION_TOKEN_BYTES: usize = 128; +const MAX_GENERATION_MARKER_BYTES: usize = 16 * 1024; +const MAX_GENERATION_LOCK_ID_BYTES: usize = 64; +const RETIRED_GENERATION_PREFIX: &str = "retired:"; +const GENERATION_LOCK_PATH_PREFIX: &str = "lock-path:"; +const GENERATION_LOCK_SUFFIX: &str = ".lock"; +const DEFAULT_GENERATION_LOCK_TIMEOUT: Duration = Duration::from_secs(5); +const GENERATION_LOCK_RETRY_INTERVAL: Duration = Duration::from_millis(25); + +#[derive(Clone, Debug)] +pub(crate) struct InstallGeneration { + path: PathBuf, + marker: GenerationMarker, + lock_id: String, + // The lock lives outside movable plugin trees while the marker remains plugin-owned. + // Retaining this handle preserves fencing across replacement and rollback without preventing + // Windows from moving the marketplace that contains the marker. + file: Arc, +} + +/// Shared generation lock held across one gateway adoption or startup. +/// +/// Retirement takes the exclusive side of the same file lock, so an installer cannot invalidate +/// and stop an endpoint until every startup that observed the old marker has either published a +/// ready gateway or failed. +pub(crate) struct ActiveGenerationGuard { + lock: Arc, +} + +impl Drop for ActiveGenerationGuard { + fn drop(&mut self) { + let _ = unlock_file(&self.lock); + } +} + +impl InstallGeneration { + pub(crate) fn capture_guarded_from_env() -> Result, String> + { + match ( + env::var_os(GENERATION_FILE_ENV), + env::var_os(GENERATION_TOKEN_ENV), + ) { + (None, None) => Ok(None), + (Some(path), Some(expected)) => { + let expected = expected.into_string().map_err(|_| { + format!("{GENERATION_TOKEN_ENV} is not valid Unicode; reinstall the plugin") + })?; + Self::capture_guarded_expected(PathBuf::from(path), &expected).map(Some) + } + (Some(_), None) => Err(format!( + "{GENERATION_TOKEN_ENV} is required with {GENERATION_FILE_ENV}; reinstall the plugin" + )), + (None, Some(_)) => Err(format!( + "{GENERATION_FILE_ENV} is required with {GENERATION_TOKEN_ENV}; reinstall the plugin" + )), + } + } + + pub(crate) fn capture(path: PathBuf) -> Result { + let (generation, guard) = Self::capture_guarded(path)?; + drop(guard); + Ok(generation) + } + + pub(crate) fn capture_guarded(path: PathBuf) -> Result<(Self, ActiveGenerationGuard), String> { + // Open the marker first. A force install can replace the entire plugin tree while this + // process waits on the external lock named by that marker. The retained marker handle + // lets us reject an old-marker/new-tree pairing after the wait. + let marker = open_generation(&path)?; + Self::capture_guarded_open_files(path, marker) + } + + pub(crate) fn capture_guarded_expected( + path: PathBuf, + expected: &str, + ) -> Result<(Self, ActiveGenerationGuard), String> { + let (generation, guard) = Self::capture_guarded(path)?; + if generation.token() != expected { + drop(guard); + return Err(retired_generation_error(&generation.path)); + } + Ok((generation, guard)) + } + + pub(crate) fn token(&self) -> &str { + self.marker.token() + } + + fn capture_guarded_open_files( + path: PathBuf, + marker: File, + ) -> Result<(Self, ActiveGenerationGuard), String> { + let observed = read_generation_marker(&marker, &path)?; + let lock_path = observed.lock_path().to_owned(); + let file = open_marker_generation_lock(&path, &lock_path)?; + Self::capture_guarded_open_files_with_lock(path, marker, file, observed) + } + + fn capture_guarded_open_files_with_lock( + path: PathBuf, + marker: File, + file: File, + observed: GenerationMarker, + ) -> Result<(Self, ActiveGenerationGuard), String> { + let lock_path = observed.lock_path().to_owned(); + let file = Arc::new(file); + let lock_id = + lock_shared_with_identity(&file, &path, &lock_path, DEFAULT_GENERATION_LOCK_TIMEOUT)?; + let locked_marker = read_generation_marker(&marker, &path)?; + let visible_marker = read_generation_marker_path(&path)?; + let visible_lock_matches = visible_generation_lock_matches(&file, &lock_path, &lock_id)?; + if observed != locked_marker + || locked_marker != visible_marker + || !visible_lock_matches + || locked_marker.is_retired() + { + let _ = unlock_file(&file); + return Err(retired_generation_error(&path)); + } + Ok(( + Self { + path, + marker: visible_marker, + lock_id, + file: file.clone(), + }, + ActiveGenerationGuard { lock: file }, + )) + } + + #[cfg(test)] + pub(crate) fn verify_current(&self) -> Result<(), String> { + loop { + if self.try_verify_current()? { + return Ok(()); + } + thread::sleep(GENERATION_LOCK_RETRY_INTERVAL); + } + } + + /// Check one lifecycle snapshot without waiting when an installer owns the transaction lock. + pub(crate) fn try_verify_current(&self) -> Result { + match try_lock_shared(&self.file) { + Ok(LockAttempt::Contended) => return Ok(false), + Ok(LockAttempt::Acquired) => {} + Err(_) => return Err(retired_generation_error(&self.path)), + } + let result = self.try_validate_locked(); + let _ = unlock_file(&self.file); + result + .map(|()| true) + .map_err(|_| retired_generation_error(&self.path)) + } + + pub(crate) fn guard_current(&self) -> Result { + lock_shared_with_timeout(&self.file, &self.path, DEFAULT_GENERATION_LOCK_TIMEOUT) + .map_err(|_| retired_generation_error(&self.path))?; + if self.try_validate_locked().is_err() { + let _ = unlock_file(&self.file); + return Err(retired_generation_error(&self.path)); + } + Ok(ActiveGenerationGuard { + lock: self.file.clone(), + }) + } + + fn try_validate_locked(&self) -> Result<(), String> { + let current_marker = read_generation_marker_path(&self.path)?; + let current_lock_matches = + visible_generation_lock_matches(&self.file, self.marker.lock_path(), &self.lock_id)?; + if current_marker == self.marker && current_lock_matches && !current_marker.is_retired() { + Ok(()) + } else { + Err(retired_generation_error(&self.path)) + } + } +} + +pub(crate) struct GenerationRetirement { + lock: Option, + lock_id: String, + path: PathBuf, + original: GenerationMarker, + changed: bool, + committed: bool, + lock_released_for_tree_mutation: bool, +} + +pub(crate) struct VisibleGenerationMarker(GenerationMarker); + +impl Drop for GenerationRetirement { + fn drop(&mut self) { + if self.changed && !self.committed { + let _ = self.restore_after_rollback(); + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +enum GenerationMarker { + Active { token: String, lock_path: PathBuf }, + Retired { token: String, lock_path: PathBuf }, +} + +impl GenerationMarker { + fn active(token: impl Into, lock_path: impl Into) -> Self { + Self::Active { + token: token.into(), + lock_path: lock_path.into(), + } + } + + fn token(&self) -> &str { + match self { + Self::Active { token, .. } | Self::Retired { token, .. } => token, + } + } + + fn lock_path(&self) -> &Path { + match self { + Self::Active { lock_path, .. } | Self::Retired { lock_path, .. } => lock_path, + } + } + + fn retired(&self) -> Self { + Self::Retired { + token: self.token().to_owned(), + lock_path: self.lock_path().to_owned(), + } + } + + fn encoded(&self) -> String { + let token = match self { + Self::Active { token, .. } => token.to_owned(), + Self::Retired { token, .. } => format!("{RETIRED_GENERATION_PREFIX}{token}"), + }; + format!( + "{token}\n{GENERATION_LOCK_PATH_PREFIX}{}\n", + encode_lock_path(self.lock_path()) + ) + } + + fn is_retired(&self) -> bool { + matches!(self, Self::Retired { .. }) + } +} + +impl GenerationRetirement { + pub(crate) fn lock_path(&self) -> &Path { + self.original.lock_path() + } + + pub(crate) fn marker_path(&self) -> &Path { + &self.path + } + + pub(crate) fn uses_lock_path(&self, path: &Path) -> Result { + let path = absolute_lock_path(path)?; + if !inspected_path_exists(&path, "MCP install generation lock")? { + return Ok(false); + } + let lock = self.lock.as_ref().ok_or_else(|| { + format!( + "MCP install generation {} has no transaction lock", + self.path.display() + ) + })?; + visible_generation_lock_matches(lock, &path, &self.lock_id) + } + + /// Follow an unchanged staged marker after its marketplace is promoted to the live path. + /// + /// The external lock remains held throughout the rename, so no MCP can adopt the replacement + /// between staging and host registration. + pub(crate) fn retarget_promoted_marker(&mut self, path: &Path) -> Result<(), String> { + if self.changed || self.committed { + return Err(format!( + "cannot retarget mutated MCP install generation {}", + self.path.display() + )); + } + if !self.visible_lock_identity_matches()? { + return Err(format!( + "failed to adopt promoted MCP install generation {} because its lock identity changed", + path.display() + )); + } + let visible = read_generation_marker_path(path)?; + if visible != self.original { + return Err(format!( + "failed to adopt promoted MCP install generation {} because its marker changed", + path.display() + )); + } + self.path = path.to_owned(); + Ok(()) + } + + /// Read the active marker protected by this already-held transaction lock. + pub(crate) fn active_visible_token(&self) -> Result { + let visible = read_generation_marker_path(&self.path)?; + if visible.is_retired() || !self.uses_lock_path(visible.lock_path())? { + return Err(retired_generation_error(&self.path)); + } + Ok(visible.token().to_owned()) + } + + pub(crate) fn visible_marker_uses_transaction_lock(&self) -> Result { + let visible = read_generation_marker_path(&self.path)?; + self.uses_lock_path(visible.lock_path()) + } + + /// Release a legacy sibling lock before Windows moves or removes its containing plugin tree. + /// + /// The marker must already be retired. A rollback reacquires this exact lock identity after + /// the tree is restored and before the old active marker is republished. + pub(crate) fn release_legacy_lock_for_tree_mutation(&mut self) -> Result<(), String> { + if self.lock_released_for_tree_mutation { + return Ok(()); + } + if !self.uses_lock_path(&generation_lock_path(&self.path))? { + return Ok(()); + } + if !self.original.is_retired() && !self.changed { + return Err(format!( + "cannot release active MCP install generation lock {}", + self.original.lock_path().display() + )); + } + let Some(file) = self.lock.take() else { + return Err(format!( + "MCP install generation {} is not locked", + self.path.display() + )); + }; + if let Err(error) = unlock_file(&file) { + self.lock = Some(file); + return Err(format!( + "failed to release MCP install generation lock {} before moving its plugin tree: {error}", + self.original.lock_path().display() + )); + } + self.lock_released_for_tree_mutation = true; + Ok(()) + } + + pub(crate) fn acquire(path: &Path) -> Result, String> { + Self::acquire_with_timeout(path, DEFAULT_GENERATION_LOCK_TIMEOUT) + } + + pub(crate) fn acquire_for_plugin( + path: &Path, + external_lock: &Path, + ) -> Result, String> { + Self::acquire_impl(path, DEFAULT_GENERATION_LOCK_TIMEOUT, Some(external_lock)) + } + + pub(crate) fn acquire_with_timeout( + path: &Path, + timeout: Duration, + ) -> Result, String> { + Self::acquire_impl(path, timeout, None) + } + + fn acquire_impl( + path: &Path, + timeout: Duration, + allowed_external_lock: Option<&Path>, + ) -> Result, String> { + if !inspected_path_exists(path, "MCP install generation")? { + return Ok(None); + } + // Open the marker before its external lock so validation can detect a plugin-tree swap + // between the two opens. The immutable lock UUID additionally detects lock replacement. + let marker = open_generation(path)?; + let observed = read_generation_marker(&marker, path)?; + let lock_path = observed.lock_path().to_owned(); + if let Some(allowed) = allowed_external_lock + && !is_legacy_sibling_lock(path, &lock_path)? + && !same_lock_path(allowed, &lock_path)? + { + return Err(format!( + "MCP install generation {} references an external lock outside its plugin layout", + path.display() + )); + } + let file = open_marker_generation_lock(path, &lock_path)?; + lock_exclusive_with_timeout(&file, path, timeout)?; + let lock_id = if is_legacy_sibling_lock(path, &lock_path)? { + ensure_generation_lock_identity_locked(&file, &lock_path)? + } else { + read_generation_lock_identity(&file, &lock_path)? + .ok_or_else(|| empty_generation_lock_error(&lock_path))? + }; + let original = read_generation_marker(&marker, path)?; + let visible = read_generation_marker_path(path)?; + let visible_lock_matches = visible_generation_lock_matches(&file, &lock_path, &lock_id)?; + if observed != original || original != visible || !visible_lock_matches { + let _ = unlock_file(&file); + return Err(retired_generation_error(path)); + } + Ok(Some(Self { + lock: Some(file), + lock_id, + path: path.to_owned(), + original, + changed: false, + committed: false, + lock_released_for_tree_mutation: false, + })) + } + + /// Persistently invalidate this generation while retaining its exclusive transaction lock. + /// + /// Existing MCPs retain the stable generation lock while the marker can be atomically + /// replaced or moved. Call [`Self::commit_replacement`] to make retirement permanent; + /// otherwise dropping the transaction restores the token before releasing the lock. + pub(crate) fn invalidate_for_replacement(&mut self) -> Result<(), String> { + self.invalidate_with(|path, retired| replace_generation_marker(path, retired, "invalidate")) + } + + fn invalidate_with( + &mut self, + write_retired: impl FnOnce(&Path, &GenerationMarker) -> Result<(), String>, + ) -> Result<(), String> { + if self.original.is_retired() { + return Ok(()); + } + if self.changed { + return Ok(()); + } + let retired = self.original.retired(); + self.lock.as_ref().ok_or_else(|| { + format!( + "MCP install generation {} is not locked", + self.path.display() + ) + })?; + self.changed = true; + if let Err(error) = write_retired(&self.path, &retired) { + let restore_error = replace_generation_marker( + &self.path, + &self.original, + "restore after failed invalidation", + ) + .err(); + if restore_error.is_none() { + self.changed = false; + } + return match restore_error { + Some(restore_error) => Err(format!("{error}; additionally {restore_error}")), + None => Err(error), + }; + } + Ok(()) + } + + /// Commit the retired marker while retaining the transaction lock until this value is dropped. + pub(crate) fn commit_replacement(&mut self) { + self.committed = true; + } + + /// Retire a promoted marker while this transaction still owns its external lock. + /// + /// Force-install rollback uses this after a staged tree is promoted. The old and replacement + /// markers intentionally share one external transaction lock, so reacquiring a second + /// retirement would deadlock. Returning the active marker lets a failed gateway refresh + /// restore the promoted generation without exposing it during destructive rollback. + pub(crate) fn retire_visible_replacement(&mut self) -> Result { + let visible = read_generation_marker_path(&self.path)?; + if visible.is_retired() { + return Err(format!( + "replacement MCP install generation {} is already retired", + self.path.display() + )); + } + if !self.uses_lock_path(visible.lock_path())? { + return Err(format!( + "failed to retire replacement MCP install generation {} because its lock identity changed", + self.path.display() + )); + } + let retired = visible.retired(); + replace_generation_marker(&self.path, &retired, "retire replacement")?; + self.verify_visible_state_for_rollback(&retired)?; + Ok(VisibleGenerationMarker(visible)) + } + + pub(crate) fn restore_visible_replacement( + &mut self, + visible: VisibleGenerationMarker, + ) -> Result<(), String> { + self.verify_visible_state_for_rollback(&visible.0.retired())?; + replace_generation_marker(&self.path, &visible.0, "restore replacement")?; + self.verify_visible_state_for_rollback(&visible.0) + } + + /// Restore an invalidated marker before a rolled-back plugin is registered again. + pub(crate) fn restore_after_rollback(&mut self) -> Result<(), String> { + if !self.changed { + self.lock = None; + self.lock_released_for_tree_mutation = false; + return Ok(()); + } + self.reacquire_transaction_lock()?; + // Never publish the retired generation's token through a replacement tree. A failed + // filesystem rollback can leave the promoted tree visible at the same path while this + // transaction still owns the shared external lock. + self.verify_visible_state_for_rollback(&self.original.retired())?; + replace_generation_marker(&self.path, &self.original, "restore")?; + // Retain the post-write check so an unexpected path swap during restoration is still + // reported before the old generation is considered active again. + self.verify_visible_state_for_rollback(&self.original)?; + self.changed = false; + self.committed = false; + self.lock = None; + self.lock_released_for_tree_mutation = false; + Ok(()) + } + + fn verify_visible_state_for_rollback( + &self, + expected_marker: &GenerationMarker, + ) -> Result<(), String> { + if !self.visible_lock_identity_matches()? { + return Err(format!( + "failed to restore MCP install generation {} because its marker or lock identity changed", + self.path.display() + )); + } + let visible_marker = read_generation_marker_path(&self.path)?; + if &visible_marker != expected_marker { + return Err(format!( + "failed to restore MCP install generation {} because its marker or lock identity changed", + self.path.display() + )); + } + Ok(()) + } + + fn visible_lock_identity_matches(&self) -> Result { + let lock = self.lock.as_ref().ok_or_else(|| { + format!( + "MCP install generation {} has no transaction lock for rollback", + self.path.display() + ) + })?; + visible_generation_lock_matches(lock, self.original.lock_path(), &self.lock_id) + } + + fn reacquire_transaction_lock(&mut self) -> Result<(), String> { + if self.lock.is_some() { + return Ok(()); + } + if !self.lock_released_for_tree_mutation { + return Err(format!( + "MCP install generation {} has no transaction lock for rollback", + self.path.display() + )); + } + let lock_path = self.original.lock_path(); + let file = open_existing_generation_lock_path(lock_path)?; + lock_exclusive_with_timeout(&file, &self.path, DEFAULT_GENERATION_LOCK_TIMEOUT)?; + let visible_identity = read_generation_lock_identity(&file, lock_path)? + .ok_or_else(|| empty_generation_lock_error(lock_path))?; + if visible_identity != self.lock_id { + let _ = unlock_file(&file); + return Err(format!( + "failed to reacquire MCP install generation lock {} because its identity changed", + lock_path.display() + )); + } + self.lock = Some(file); + self.lock_released_for_tree_mutation = false; + Ok(()) + } +} + +/// Distinguishes an absent path from Windows reporting `NotFound` when an intermediate component +/// is a file. Treating the latter as absent would silently skip retirement or lock validation. +fn inspected_path_exists(path: &Path, description: &str) -> Result { + match fs::metadata(path) { + Ok(_) => Ok(true), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + let mut ancestor = path.parent(); + while let Some(parent) = ancestor { + match fs::metadata(parent) { + Ok(metadata) if metadata.is_dir() => return Ok(false), + Ok(_) => { + return Err(format!( + "failed to inspect {description} {}: parent {} is not a directory", + path.display(), + parent.display() + )); + } + Err(parent_error) if parent_error.kind() == std::io::ErrorKind::NotFound => { + ancestor = parent.parent(); + } + Err(parent_error) => { + return Err(format!( + "failed to inspect {description} {}: failed to inspect parent {}: {parent_error}", + path.display(), + parent.display() + )); + } + } + } + Ok(false) + } + Err(error) => Err(format!( + "failed to inspect {description} {}: {error}", + path.display() + )), + } +} + +fn replace_generation_marker( + path: &Path, + marker: &GenerationMarker, + operation: &str, +) -> Result<(), String> { + atomic_write(path, marker.encoded().as_bytes()).map_err(|error| { + format!( + "failed to {operation} MCP install generation {}: {error}", + path.display() + ) + }) +} + +fn lock_shared_with_timeout(file: &File, path: &Path, timeout: Duration) -> Result<(), String> { + lock_with_timeout(file, path, timeout, false) +} + +/// Acquire a shared generation lock whose immutable inode identity is initialized and readable. +/// +/// A zero-length legacy sibling lock can remain after an interrupted first install. Initialization +/// briefly upgrades through an exclusive lock, then reacquires the shared lock and lets the +/// caller's marker/visible-path validation close the upgrade gap. Explicit external locks are +/// never initialized through a marker because their paths are marker-controlled. +fn lock_shared_with_identity( + file: &File, + path: &Path, + lock_path: &Path, + timeout: Duration, +) -> Result { + lock_shared_with_timeout(file, path, timeout)?; + match read_generation_lock_identity(file, lock_path) { + Ok(Some(identity)) => return Ok(identity), + Ok(None) if !is_legacy_sibling_lock(path, lock_path)? => { + let _ = unlock_file(file); + return Err(empty_generation_lock_error(lock_path)); + } + Ok(None) => { + let _ = unlock_file(file); + } + Err(error) => { + let _ = unlock_file(file); + return Err(error); + } + } + + lock_exclusive_with_timeout(file, path, timeout)?; + let initialized = ensure_generation_lock_identity_locked(file, lock_path); + let _ = unlock_file(file); + initialized?; + + lock_shared_with_timeout(file, path, timeout)?; + read_generation_lock_identity(file, lock_path)?.ok_or_else(|| { + format!( + "MCP install generation lock {} remained empty after initialization", + lock_path.display() + ) + }) +} + +fn empty_generation_lock_error(lock_path: &Path) -> String { + format!( + "MCP install generation lock {} is empty", + lock_path.display() + ) +} + +fn lock_exclusive_with_timeout(file: &File, path: &Path, timeout: Duration) -> Result<(), String> { + lock_with_timeout(file, path, timeout, true) +} + +fn lock_with_timeout( + file: &File, + path: &Path, + timeout: Duration, + exclusive: bool, +) -> Result<(), String> { + let deadline = Instant::now() + timeout; + loop { + let result = if exclusive { + try_lock_exclusive(file) + } else { + try_lock_shared(file) + }; + match result { + Ok(LockAttempt::Acquired) => return Ok(()), + Ok(LockAttempt::Contended) => { + if Instant::now() >= deadline { + return Err(format!( + "timed out waiting for MCP install generation lock {}", + path.display() + )); + } + thread::sleep(GENERATION_LOCK_RETRY_INTERVAL.min(timeout)); + } + Err(error) => { + return Err(format!( + "failed to lock MCP install generation {}: {error}", + path.display() + )); + } + } + } +} + +#[cfg(test)] +pub(crate) fn write_new_generation(path: &Path) -> Result<(), String> { + write_new_generation_with_token(path).map(|_| ()) +} + +#[cfg(test)] +pub(crate) fn write_new_generation_with_token(path: &Path) -> Result { + let lock_path = generation_lock_path(path); + write_new_generation_with_token_at(path, &lock_path) +} + +#[cfg(test)] +pub(crate) fn write_legacy_generation(path: &Path, token: &str) -> Result<(), String> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .map_err(|error| format!("failed to create {}: {error}", parent.display()))?; + } + let lock_path = generation_lock_path(path); + let file = open_generation_lock_path(&lock_path)?; + lock_exclusive_with_timeout(&file, path, DEFAULT_GENERATION_LOCK_TIMEOUT)?; + if let Err(error) = ensure_generation_lock_identity_locked(&file, &lock_path) { + let _ = unlock_file(&file); + return Err(error); + } + let result = atomic_write(path, format!("{token}\n").as_bytes()); + let _ = unlock_file(&file); + result +} + +pub(crate) fn write_new_generation_with_token_at( + path: &Path, + lock_path: &Path, +) -> Result { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .map_err(|error| format!("failed to create {}: {error}", parent.display()))?; + } + if let Some(parent) = lock_path.parent() { + fs::create_dir_all(parent) + .map_err(|error| format!("failed to create {}: {error}", parent.display()))?; + } + let file = open_generation_lock_path(lock_path)?; + lock_exclusive_with_timeout(&file, path, DEFAULT_GENERATION_LOCK_TIMEOUT)?; + if let Err(error) = ensure_generation_lock_identity_locked(&file, lock_path) { + let _ = unlock_file(&file); + return Err(error); + } + let token = uuid::Uuid::now_v7().to_string(); + let marker = GenerationMarker::active(&token, absolute_lock_path(lock_path)?); + let result = atomic_write(path, marker.encoded().as_bytes()); + let _ = unlock_file(&file); + result.map(|()| token) +} + +pub(crate) fn write_staged_generation_with_token( + path: &Path, + active_lock_path: &Path, +) -> Result { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .map_err(|error| format!("failed to create {}: {error}", parent.display()))?; + } + let token = uuid::Uuid::now_v7().to_string(); + let marker = GenerationMarker::active(&token, absolute_lock_path(active_lock_path)?); + atomic_write(path, marker.encoded().as_bytes()).map(|()| token) +} + +fn absolute_lock_path(path: &Path) -> Result { + if path.is_absolute() { + return Ok(path.to_owned()); + } + env::current_dir() + .map(|current| current.join(path)) + .map_err(|error| { + format!( + "failed to resolve generation lock {}: {error}", + path.display() + ) + }) +} + +fn generation_lock_path(path: &Path) -> PathBuf { + let mut lock = path.as_os_str().to_os_string(); + lock.push(GENERATION_LOCK_SUFFIX); + PathBuf::from(lock) +} + +fn is_legacy_sibling_lock(marker_path: &Path, lock_path: &Path) -> Result { + same_lock_path(&generation_lock_path(marker_path), lock_path) +} + +fn same_lock_path(left: &Path, right: &Path) -> Result { + let left = absolute_lock_path(left)?; + let right = absolute_lock_path(right)?; + if left == right { + return Ok(true); + } + Ok(left + .canonicalize() + .ok() + .zip(right.canonicalize().ok()) + .is_some_and(|(left, right)| left == right)) +} + +#[cfg(test)] +fn open_generation_lock(path: &Path) -> Result { + let lock_path = generation_lock_path(path); + open_generation_lock_path(&lock_path) +} + +fn open_generation_lock_path(lock_path: &Path) -> Result { + let mut options = OpenOptions::new(); + options.create(true).truncate(false).read(true).write(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600).custom_flags(libc::O_NOFOLLOW); + } + options.open(lock_path).map_err(|error| { + format!( + "failed to open MCP install generation lock {}: {error}", + lock_path.display() + ) + }) +} + +fn open_marker_generation_lock(marker_path: &Path, lock_path: &Path) -> Result { + // Legacy one-line markers derive a sibling lock. Creating only that deterministic path keeps + // old installs and Hermes upgrades compatible without allowing a marker to create an + // arbitrary external file. New plugin markers always point at a pre-initialized state lock. + if is_legacy_sibling_lock(marker_path, lock_path)? { + open_generation_lock_path(lock_path) + } else { + open_existing_generation_lock_path(lock_path) + } +} + +fn open_existing_generation_lock_path(lock_path: &Path) -> Result { + OpenOptions::new() + .read(true) + .open(lock_path) + .map_err(|error| { + format!( + "failed to open MCP install generation lock {}: {error}", + lock_path.display() + ) + }) +} + +fn ensure_generation_lock_identity_locked(file: &File, lock_path: &Path) -> Result { + if let Some(identity) = read_generation_lock_identity(file, lock_path)? { + return Ok(identity); + } + let identity = uuid::Uuid::now_v7().to_string(); + write_generation_lock_identity(file, lock_path, &identity)?; + Ok(identity) +} + +fn write_generation_lock_identity( + file: &File, + lock_path: &Path, + identity: &str, +) -> Result<(), String> { + let mut writer = file; + writer.seek(SeekFrom::Start(0)).map_err(|error| { + format!( + "failed to seek MCP install generation lock {}: {error}", + lock_path.display() + ) + })?; + file.set_len(0).map_err(|error| { + format!( + "failed to truncate MCP install generation lock {}: {error}", + lock_path.display() + ) + })?; + writer + .write_all(format!("{identity}\n").as_bytes()) + .map_err(|error| { + format!( + "failed to write MCP install generation lock {}: {error}", + lock_path.display() + ) + })?; + writer.sync_all().map_err(|error| { + format!( + "failed to sync MCP install generation lock {}: {error}", + lock_path.display() + ) + }) +} + +#[cfg(test)] +fn read_generation_lock_identity_path(path: &Path) -> Result { + let marker = read_generation_marker_path(path)?; + let lock_path = marker.lock_path(); + let file = open_existing_generation_lock_path(lock_path)?; + read_generation_lock_identity(&file, lock_path)?.ok_or_else(|| { + format!( + "MCP install generation lock {} is empty", + lock_path.display() + ) + }) +} + +/// Verify that the visible lock path still names the locked generation and retains its UUID. +/// +/// Windows byte-range locks reject reads through every other handle, including handles opened by +/// the locking process. Compare file identities there, then read the UUID through the owning +/// handle. Unix locks are advisory, so reading the visible path preserves the same check directly. +fn visible_generation_lock_matches( + locked: &File, + lock_path: &Path, + expected_identity: &str, +) -> Result { + #[cfg(windows)] + { + let visible = open_existing_generation_lock_path(lock_path)?; + if windows_file_identity(locked, lock_path)? != windows_file_identity(&visible, lock_path)? + { + return Ok(false); + } + return read_generation_lock_identity(locked, lock_path) + .map(|identity| identity.as_deref() == Some(expected_identity)); + } + #[cfg(not(windows))] + { + let visible = open_existing_generation_lock_path(lock_path)?; + #[cfg(unix)] + if unix_file_identity(locked, lock_path)? != unix_file_identity(&visible, lock_path)? { + return Ok(false); + } + read_generation_lock_identity(&visible, lock_path) + .map(|identity| identity.as_deref() == Some(expected_identity)) + } +} + +#[cfg(unix)] +fn unix_file_identity(file: &File, lock_path: &Path) -> Result<(u64, u64), String> { + use std::os::unix::fs::MetadataExt; + + file.metadata() + .map(|metadata| (metadata.dev(), metadata.ino())) + .map_err(|error| { + format!( + "failed to identify MCP install generation lock {}: {error}", + lock_path.display() + ) + }) +} + +#[cfg(windows)] +fn windows_file_identity(file: &File, lock_path: &Path) -> Result<(u64, [u8; 16]), String> { + use std::os::windows::io::AsRawHandle; + use windows_sys::Win32::Storage::FileSystem::{ + FILE_ID_INFO, FileIdInfo, GetFileInformationByHandleEx, + }; + + let mut information = FILE_ID_INFO::default(); + // SAFETY: `file` owns a live handle and `information` is writable for the duration of the + // synchronous call. + if unsafe { + GetFileInformationByHandleEx( + file.as_raw_handle(), + FileIdInfo, + (&raw mut information).cast(), + std::mem::size_of::() as u32, + ) + } == 0 + { + return Err(format!( + "failed to identify MCP install generation lock {}: {}", + lock_path.display(), + std::io::Error::last_os_error() + )); + } + Ok(( + information.VolumeSerialNumber, + information.FileId.Identifier, + )) +} + +fn read_generation_lock_identity(file: &File, lock_path: &Path) -> Result, String> { + let mut raw = String::new(); + let mut reader = file; + reader.seek(SeekFrom::Start(0)).map_err(|error| { + format!( + "failed to seek MCP install generation lock {}: {error}", + lock_path.display() + ) + })?; + reader + .take(MAX_GENERATION_LOCK_ID_BYTES.saturating_add(1) as u64) + .read_to_string(&mut raw) + .map_err(|error| { + format!( + "failed to read MCP install generation lock {}: {error}", + lock_path.display() + ) + })?; + if raw.len() > MAX_GENERATION_LOCK_ID_BYTES { + return Err(format!( + "MCP install generation lock {} exceeds the {MAX_GENERATION_LOCK_ID_BYTES}-byte limit", + lock_path.display() + )); + } + let identity = raw.trim(); + if identity.is_empty() { + return Ok(None); + } + uuid::Uuid::parse_str(identity) + .map(|identity| Some(identity.to_string())) + .map_err(|error| { + format!( + "MCP install generation lock {} has an invalid identity: {error}", + lock_path.display() + ) + }) +} + +fn open_generation(path: &Path) -> Result { + OpenOptions::new().read(true).open(path).map_err(|error| { + format!( + "failed to open MCP install generation {}: {error}", + path.display() + ) + }) +} + +fn read_generation_marker_path(path: &Path) -> Result { + let file = open_generation(path)?; + read_generation_marker(&file, path) +} + +fn read_generation_marker(file: &File, path: &Path) -> Result { + let mut raw = String::new(); + let mut reader = file; + reader.seek(SeekFrom::Start(0)).map_err(|error| { + format!( + "failed to seek MCP install generation {}: {error}", + path.display() + ) + })?; + reader + .take(MAX_GENERATION_MARKER_BYTES.saturating_add(1) as u64) + .read_to_string(&mut raw) + .map_err(|error| { + format!( + "failed to read MCP install generation {}: {error}", + path.display() + ) + })?; + if raw.len() > MAX_GENERATION_MARKER_BYTES { + return Err(format!( + "MCP install generation {} exceeds the {MAX_GENERATION_MARKER_BYTES}-byte limit", + path.display() + )); + } + let mut lines = raw.lines(); + let token = lines.next().unwrap_or_default().trim(); + if token.is_empty() { + return Err(format!( + "MCP install generation {} is empty", + path.display() + )); + } + let (retired, token) = match token.strip_prefix(RETIRED_GENERATION_PREFIX) { + Some("") => { + return Err(format!( + "MCP install generation {} has a retired marker without a token", + path.display() + )); + } + Some(token) => (true, token), + None => (false, token), + }; + if token.len() > MAX_GENERATION_TOKEN_BYTES { + return Err(format!( + "MCP install generation token in {} exceeds the {MAX_GENERATION_TOKEN_BYTES}-byte limit", + path.display() + )); + } + let lock_path = match lines.next() { + Some(encoded) => { + let encoded = encoded + .strip_prefix(GENERATION_LOCK_PATH_PREFIX) + .ok_or_else(|| { + format!( + "MCP install generation {} has an invalid lock-path record", + path.display() + ) + })?; + if lines.next().is_some() { + return Err(format!( + "MCP install generation {} has unexpected trailing records", + path.display() + )); + } + let lock_path = decode_lock_path(encoded).map_err(|error| { + format!( + "MCP install generation {} has an invalid lock path: {error}", + path.display() + ) + })?; + if !lock_path.is_absolute() { + return Err(format!( + "MCP install generation {} has a non-absolute external lock path", + path.display() + )); + } + lock_path + } + None => absolute_lock_path(&generation_lock_path(path))?, + }; + Ok(if retired { + GenerationMarker::Retired { + token: token.to_owned(), + lock_path, + } + } else { + GenerationMarker::active(token, lock_path) + }) +} + +fn encode_lock_path(path: &Path) -> String { + #[cfg(unix)] + let bytes = { + use std::os::unix::ffi::OsStrExt; + path.as_os_str().as_bytes().to_vec() + }; + #[cfg(windows)] + let bytes = { + use std::os::windows::ffi::OsStrExt; + path.as_os_str() + .encode_wide() + .flat_map(u16::to_le_bytes) + .collect::>() + }; + #[cfg(not(any(unix, windows)))] + let bytes = path.to_string_lossy().as_bytes().to_vec(); + base64::engine::general_purpose::STANDARD.encode(bytes) +} + +fn decode_lock_path(encoded: &str) -> Result { + let bytes = base64::engine::general_purpose::STANDARD + .decode(encoded) + .map_err(|error| error.to_string())?; + #[cfg(unix)] + let path = { + use std::os::unix::ffi::OsStringExt; + PathBuf::from(std::ffi::OsString::from_vec(bytes)) + }; + #[cfg(windows)] + let path = { + use std::os::windows::ffi::OsStringExt; + let pairs = bytes.chunks_exact(2); + if !pairs.remainder().is_empty() { + return Err("UTF-16 lock path has an odd byte length".into()); + } + let wide = pairs + .map(|pair| u16::from_le_bytes([pair[0], pair[1]])) + .collect::>(); + PathBuf::from(std::ffi::OsString::from_wide(&wide)) + }; + #[cfg(not(any(unix, windows)))] + let path = PathBuf::from(String::from_utf8(bytes).map_err(|error| error.to_string())?); + if path.as_os_str().is_empty() { + return Err("lock path is empty".into()); + } + Ok(path) +} + +fn retired_generation_error(path: &Path) -> String { + format!( + "plugin MCP install generation at {} has been retired", + path.display() + ) +} + +#[cfg(test)] +#[path = "../../tests/coverage/shared/install_generation_tests.rs"] +mod tests; diff --git a/crates/cli/src/installation/marketplace/assets.rs b/crates/cli/src/installation/marketplace/assets.rs new file mode 100644 index 000000000..cf624014b --- /dev/null +++ b/crates/cli/src/installation/marketplace/assets.rs @@ -0,0 +1,117 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Generated local marketplace and plugin manifest files. + +use std::env; +use std::fs; +use std::path::Path; + +use serde_json::Value; + +use crate::installation::generation::{ + write_new_generation_with_token_at, write_staged_generation_with_token, +}; + +use super::state::{PluginInstallOptions, PluginLayout, remove_path, write_json}; +use super::{MARKETPLACE_NAME, MarketplaceHost, PLUGIN_NAME}; + +pub(super) fn write_plugin_marketplace( + host: impl MarketplaceHost, + layout: &PluginLayout, + relay: &Path, + options: &PluginInstallOptions, +) -> Result<(), String> { + write_plugin_marketplace_for_generation( + host, + layout, + relay, + &layout.generation_fence, + &layout.generation_lock, + true, + options, + ) +} + +pub(super) fn write_plugin_marketplace_for_generation( + host: impl MarketplaceHost, + layout: &PluginLayout, + relay: &Path, + active_generation_fence: &Path, + active_generation_lock: &Path, + initialize_generation_lock: bool, + options: &PluginInstallOptions, +) -> Result<(), String> { + if options.dry_run { + println!("write {}", layout.marketplace_manifest.display()); + println!("write {}", layout.plugin_manifest.display()); + println!("write {}", layout.mcp_config.display()); + println!("write {}", layout.generation_fence.display()); + println!("write {}", layout.hooks_path.display()); + return Ok(()); + } + remove_path(&layout.plugin_root, options)?; + fs::create_dir_all( + layout + .plugin_root + .parent() + .unwrap_or(&layout.marketplace_root), + ) + .map_err(|error| format!("failed to create {}: {error}", layout.plugin_root.display()))?; + fs::create_dir_all(layout.hooks_path.parent().unwrap_or(&layout.plugin_root)) + .map_err(|error| format!("failed to create {}: {error}", layout.hooks_path.display()))?; + write_json(&layout.marketplace_manifest, &marketplace_manifest(host))?; + write_json(&layout.plugin_manifest, &plugin_manifest(host))?; + let generation_token = if initialize_generation_lock { + write_new_generation_with_token_at(&layout.generation_fence, active_generation_lock) + } else { + write_staged_generation_with_token(&layout.generation_fence, active_generation_lock) + }?; + write_json( + &layout.mcp_config, + &plugin_mcp_config(host, relay, active_generation_fence, &generation_token)?, + )?; + write_json( + &layout.hooks_path, + &plugin_hooks(host, relay, active_generation_fence, &generation_token)?, + )?; + Ok(()) +} + +pub(super) fn marketplace_manifest(host: impl MarketplaceHost) -> Value { + host.marketplace_manifest(MARKETPLACE_NAME, PLUGIN_NAME) +} + +pub(super) fn plugin_manifest(host: impl MarketplaceHost) -> Value { + host.plugin_manifest(PLUGIN_NAME) +} + +pub(super) fn plugin_mcp_config( + host: impl MarketplaceHost, + relay: &Path, + generation_fence: &Path, + generation_token: &str, +) -> Result { + let generation_fence = absolute_or_self(generation_fence)?; + let server = crate::mcp::persistent_server(relay, &generation_fence, generation_token); + host.plugin_mcp_config(server) +} + +fn absolute_or_self(path: &Path) -> Result { + if path.is_absolute() { + return Ok(path.to_owned()); + } + env::current_dir() + .map(|current| current.join(path)) + .map_err(|error| format!("failed to resolve relative generation fence: {error}")) +} + +pub(super) fn plugin_hooks( + host: impl MarketplaceHost, + relay: &Path, + generation_fence: &Path, + generation_token: &str, +) -> Result { + let generation_fence = absolute_or_self(generation_fence)?; + host.plugin_hooks(relay, &generation_fence, generation_token) +} diff --git a/crates/cli/src/plugin_install/host.rs b/crates/cli/src/installation/marketplace/host.rs similarity index 62% rename from crates/cli/src/plugin_install/host.rs rename to crates/cli/src/installation/marketplace/host.rs index d8c9f0928..a49ff9182 100644 --- a/crates/cli/src/plugin_install/host.rs +++ b/crates/cli/src/installation/marketplace/host.rs @@ -5,31 +5,28 @@ use std::env; use std::path::{Path, PathBuf}; -use std::process::Command; use serde_json::Value; #[cfg(test)] use serde_json::json; -use crate::config::PluginHost; - -use super::state::{PluginInstallOptions, PluginLayout}; -use super::{MARKETPLACE_NAME, PLUGIN_NAME, RELAY_COMMAND, host_cli}; +use super::state::PluginInstallOptions; +use super::{MARKETPLACE_NAME, MarketplaceHost, PLUGIN_NAME, RELAY_COMMAND}; pub(super) fn run_host_marketplace_registration( - host: PluginHost, - layout: &PluginLayout, + host: impl MarketplaceHost, + marketplace_root: &Path, options: &PluginInstallOptions, runner: &dyn CommandRunner, ) -> Result<(), String> { run_command( - host_cli(host), + host.executable(), &[ "plugin".into(), "marketplace".into(), "add".into(), - layout.marketplace_root.display().to_string(), + marketplace_root.display().to_string(), ], options, runner, @@ -37,71 +34,39 @@ pub(super) fn run_host_marketplace_registration( } pub(super) fn run_host_plugin_registration( - host: PluginHost, + host: impl MarketplaceHost, options: &PluginInstallOptions, runner: &dyn CommandRunner, ) -> Result<(), String> { - match host { - PluginHost::Codex => run_command( - host_cli(host), - &[ - "plugin".into(), - "add".into(), - format!("{PLUGIN_NAME}@{MARKETPLACE_NAME}"), - ], - options, - runner, - ), - PluginHost::ClaudeCode => run_command( - host_cli(host), - &[ - "plugin".into(), - "install".into(), - format!("{PLUGIN_NAME}@{MARKETPLACE_NAME}"), - "--scope".into(), - "user".into(), - ], - options, - runner, - ), - PluginHost::All => unreachable!("all is expanded before host registration"), - } + run_command( + host.executable(), + &host.plugin_registration_args(&format!("{PLUGIN_NAME}@{MARKETPLACE_NAME}")), + options, + runner, + ) } pub(super) fn run_host_plugin_removal( - host: PluginHost, + host: impl MarketplaceHost, options: &PluginInstallOptions, runner: &dyn CommandRunner, ) -> Result<(), String> { - match host { - PluginHost::Codex => run_command( - host_cli(host), - &[ - "plugin".into(), - "remove".into(), - format!("{PLUGIN_NAME}@{MARKETPLACE_NAME}"), - ], - options, - runner, - )?, - PluginHost::ClaudeCode => run_command( - host_cli(host), - &["plugin".into(), "uninstall".into(), PLUGIN_NAME.into()], - options, - runner, - )?, - PluginHost::All => unreachable!("all is expanded before host unregistration"), - } + run_command( + host.executable(), + &host.plugin_removal_args(PLUGIN_NAME, &format!("{PLUGIN_NAME}@{MARKETPLACE_NAME}")), + options, + runner, + )?; Ok(()) } pub(super) fn run_host_marketplace_removal( - host: PluginHost, + host: impl MarketplaceHost, options: &PluginInstallOptions, runner: &dyn CommandRunner, ) -> Result<(), String> { run_command( - host_cli(host), + host.executable(), &[ "plugin".into(), "marketplace".into(), @@ -114,9 +79,9 @@ pub(super) fn run_host_marketplace_removal( } #[derive(Debug, Clone)] -pub(super) struct HostRegistrationReport { - pub(super) host_plugin_registered: bool, - pub(super) host_marketplace_registered: bool, +pub(crate) struct HostRegistrationReport { + pub(crate) host_plugin_registered: bool, + pub(crate) host_marketplace_registered: bool, } impl HostRegistrationReport { @@ -136,7 +101,7 @@ impl HostRegistrationReport { #[cfg(test)] pub(super) fn validate_host_registration( - host: PluginHost, + host: impl MarketplaceHost, options: &PluginInstallOptions, runner: &dyn CommandRunner, ) -> Result { @@ -153,14 +118,14 @@ pub(super) fn validate_host_registration( } Err(format!( "{} plugin host registration is incomplete: missing {}", - host_cli(host), + host.executable(), missing.join(", ") )) } } pub(super) fn host_registration_report( - host: PluginHost, + host: impl MarketplaceHost, options: &PluginInstallOptions, runner: &dyn CommandRunner, ) -> Result { @@ -171,16 +136,26 @@ pub(super) fn host_registration_report( }); } require_host_cli(host, options, runner)?; - Ok(match host { - PluginHost::ClaudeCode => HostRegistrationReport { - host_plugin_registered: claude_plugin_registered(options, runner)?, - host_marketplace_registered: claude_marketplace_registered(options, runner)?, - }, - PluginHost::Codex => HostRegistrationReport { - host_plugin_registered: codex_plugin_registered(options, runner)?, - host_marketplace_registered: codex_marketplace_registered(options, runner)?, - }, - PluginHost::All => unreachable!("all is expanded before host registration checks"), + host.registration_report(options, runner) +} + +pub(crate) fn claude_registration_report( + options: &PluginInstallOptions, + runner: &dyn CommandRunner, +) -> Result { + Ok(HostRegistrationReport { + host_plugin_registered: claude_plugin_registered(options, runner)?, + host_marketplace_registered: claude_marketplace_registered(options, runner)?, + }) +} + +pub(crate) fn codex_registration_report( + options: &PluginInstallOptions, + runner: &dyn CommandRunner, +) -> Result { + Ok(HostRegistrationReport { + host_plugin_registered: codex_plugin_registered(options, runner)?, + host_marketplace_registered: codex_marketplace_registered(options, runner)?, }) } @@ -283,7 +258,7 @@ fn parse_json_command_output(command: &str, output: CommandOutput) -> Result Result { @@ -292,10 +267,33 @@ pub(super) fn require_relay( } runner .resolve_executable(RELAY_COMMAND)? - .ok_or_else(|| "required `nemo-relay` executable was not found on PATH".into()) + .map(Ok) + .unwrap_or_else(|| runner.current_executable()) + .map(|path| path.canonicalize().unwrap_or(path)) + .map(crate::process::portable_executable_path) +} + +pub(crate) fn validate_relay_hook_forward( + relay: &Path, + options: &PluginInstallOptions, + runner: &dyn CommandRunner, +) -> Result<(), String> { + if options.dry_run { + return Ok(()); + } + let args = ["hook-forward".into(), "--help".into()]; + let status = runner.run_quiet(relay, &args)?; + if status == 0 { + Ok(()) + } else { + Err(format!( + "{} failed with exit code {status}; installed hooks require `nemo-relay hook-forward` support", + format_command(&relay.display().to_string(), &args) + )) + } } -pub(super) fn validate_relay_plugin_shim( +pub(crate) fn validate_relay_mcp( relay: &Path, options: &PluginInstallOptions, runner: &dyn CommandRunner, @@ -303,33 +301,45 @@ pub(super) fn validate_relay_plugin_shim( if options.dry_run { return Ok(()); } - let args = ["plugin-shim".into(), "hook".into(), "--help".into()]; + let args = ["mcp".into(), "--help".into()]; let status = runner.run_quiet(relay, &args)?; if status == 0 { Ok(()) } else { Err(format!( - "{} failed with exit code {status}; installed hooks require `nemo-relay plugin-shim hook` support", + "{} failed with exit code {status}; coding-agent plugins require native `nemo-relay mcp` support", format_command(&relay.display().to_string(), &args) )) } } -pub(super) fn require_host_cli( - host: PluginHost, +pub(crate) fn require_host_cli( + host: impl MarketplaceHost, options: &PluginInstallOptions, runner: &dyn CommandRunner, ) -> Result<(), String> { if options.dry_run { return Ok(()); } - let cli = host_cli(host); + let cli = host.executable(); runner .resolve_executable(cli)? .map(|_| ()) .ok_or_else(|| format!("required `{cli}` CLI was not found on PATH")) } +pub(crate) fn validate_host_version( + host: impl MarketplaceHost, + options: &PluginInstallOptions, + runner: &dyn CommandRunner, +) -> Result<(), String> { + if options.dry_run { + return Ok(()); + } + let output = run_capture_command(host.executable(), &["--version".into()], options, runner)?; + host.validate_version_output(&output.stdout) +} + pub(super) fn run_command( program: &str, args: &[String], @@ -406,32 +416,14 @@ pub(super) fn format_command(program: &str, args: &[String]) -> String { "$ {}", parts .iter() - .map(|part| shell_quote(part)) + .map(|part| crate::process::shell_quote_arg_for_platform(part, cfg!(windows))) .collect::>() .join(" ") ) } -fn shell_quote(raw: &str) -> String { - if raw.chars().all(|ch| { - ch.is_ascii_alphanumeric() - || matches!(ch, '/' | '\\' | ':' | '.' | '_' | '-' | '=' | '@' | '+') - }) { - raw.into() - } else { - let mut escaped = String::new(); - for ch in raw.chars() { - if matches!(ch, '"' | '\\' | '$' | '`') { - escaped.push('\\'); - } - escaped.push(ch); - } - format!("\"{escaped}\"") - } -} - #[derive(Debug, Clone)] -pub(super) struct CommandOutput { +pub(crate) struct CommandOutput { pub(super) status: i32, pub(super) stdout: String, pub(super) stderr: String, @@ -447,54 +439,35 @@ impl CommandOutput { } } -pub(super) trait CommandRunner { +pub(crate) trait CommandRunner { + fn current_executable(&self) -> Result; fn resolve_executable(&self, command: &str) -> Result, String>; fn run(&self, program: &Path, args: &[String]) -> Result; fn run_quiet(&self, program: &Path, args: &[String]) -> Result; fn run_capture(&self, program: &Path, args: &[String]) -> Result; } -pub(super) struct RealCommandRunner; +pub(crate) struct RealCommandRunner; impl CommandRunner for RealCommandRunner { + fn current_executable(&self) -> Result { + env::current_exe() + .map_err(|error| format!("failed to resolve current nemo-relay executable: {error}")) + } + fn resolve_executable(&self, command: &str) -> Result, String> { - Ok(find_executable(command)) + Ok(crate::process::resolve_executable(command)) } fn run(&self, program: &Path, args: &[String]) -> Result { - #[cfg(windows)] - if is_windows_command_script(program) { - let status = Command::new(env::var_os("COMSPEC").unwrap_or_else(|| "cmd.exe".into())) - .args(["/d", "/s", "/c"]) - .arg(windows_command_line(program, args)) - .status() - .map_err(|error| format!("failed to run {}: {error}", program.display()))?; - return Ok(status.code().unwrap_or(1)); - } - - let status = Command::new(program) - .args(args) + let status = crate::process::std_command(&command_argv(program, args)) .status() .map_err(|error| format!("failed to run {}: {error}", program.display()))?; Ok(status.code().unwrap_or(1)) } fn run_quiet(&self, program: &Path, args: &[String]) -> Result { - #[cfg(windows)] - if is_windows_command_script(program) { - let status = Command::new(env::var_os("COMSPEC").unwrap_or_else(|| "cmd.exe".into())) - .args(["/d", "/s", "/c"]) - .arg(windows_command_line(program, args)) - .stdin(std::process::Stdio::null()) - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .status() - .map_err(|error| format!("failed to run {}: {error}", program.display()))?; - return Ok(status.code().unwrap_or(1)); - } - - let status = Command::new(program) - .args(args) + let status = crate::process::std_command(&command_argv(program, args)) .stdin(std::process::Stdio::null()) .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) @@ -504,24 +477,19 @@ impl CommandRunner for RealCommandRunner { } fn run_capture(&self, program: &Path, args: &[String]) -> Result { - #[cfg(windows)] - if is_windows_command_script(program) { - let output = Command::new(env::var_os("COMSPEC").unwrap_or_else(|| "cmd.exe".into())) - .args(["/d", "/s", "/c"]) - .arg(windows_command_line(program, args)) - .output() - .map_err(|error| format!("failed to run {}: {error}", program.display()))?; - return Ok(command_output(output)); - } - - let output = Command::new(program) - .args(args) + let output = crate::process::std_command(&command_argv(program, args)) .output() .map_err(|error| format!("failed to run {}: {error}", program.display()))?; Ok(command_output(output)) } } +fn command_argv(program: &Path, args: &[String]) -> Vec { + std::iter::once(program.display().to_string()) + .chain(args.iter().cloned()) + .collect() +} + fn command_output(output: std::process::Output) -> CommandOutput { CommandOutput { status: output.status.code().unwrap_or(1), @@ -529,53 +497,3 @@ fn command_output(output: std::process::Output) -> CommandOutput { stderr: String::from_utf8_lossy(&output.stderr).into_owned(), } } - -fn find_executable(command: &str) -> Option { - let path = env::var_os("PATH")?; - let candidates = env::split_paths(&path); - let extensions = executable_extensions(command); - for dir in candidates { - for extension in &extensions { - let candidate = dir.join(format!("{command}{extension}")); - if candidate.is_file() { - return Some(candidate); - } - } - } - None -} - -fn executable_extensions(command: &str) -> Vec { - if cfg!(windows) && Path::new(command).extension().is_none() { - env::var("PATHEXT") - .unwrap_or_else(|_| ".EXE;.CMD;.BAT;.COM".into()) - .split(';') - .map(str::to_string) - .collect() - } else { - vec![String::new()] - } -} - -#[cfg(windows)] -fn is_windows_command_script(program: &Path) -> bool { - program - .extension() - .and_then(|extension| extension.to_str()) - .is_some_and(|extension| { - extension.eq_ignore_ascii_case("cmd") || extension.eq_ignore_ascii_case("bat") - }) -} - -#[cfg(windows)] -fn windows_command_line(program: &Path, args: &[String]) -> String { - std::iter::once(windows_command_argument(&program.display().to_string())) - .chain(args.iter().map(|arg| windows_command_argument(arg))) - .collect::>() - .join(" ") -} - -#[cfg(windows)] -fn windows_command_argument(argument: &str) -> String { - format!("\"{}\"", argument.replace('"', "\\\"")) -} diff --git a/crates/cli/src/installation/marketplace/mod.rs b/crates/cli/src/installation/marketplace/mod.rs new file mode 100644 index 000000000..38d3daa44 --- /dev/null +++ b/crates/cli/src/installation/marketplace/mod.rs @@ -0,0 +1,1980 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Local marketplace installer for Claude Code and Codex plugins. + +mod assets; +pub(crate) mod host; +mod setup; +mod spec; +pub(crate) mod state; + +pub(crate) use spec::{MarketplaceHost, PluginSetupSnapshot}; + +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::ExitCode; +use std::time::Duration; + +use serde::Serialize; +use serde_json::{Value, json}; + +use crate::error::CliError; +use crate::installation::generation::{ + GENERATION_FILE_NAME, GenerationRetirement, InstallGeneration, +}; +use crate::installation::{InstallRequest, UninstallRequest}; + +use crate::installation::operation_lock::{DEFAULT_OPERATION_LOCK_TIMEOUT, PluginOperationLock}; +use assets::{ + marketplace_manifest, plugin_hooks, plugin_manifest, plugin_mcp_config, + write_plugin_marketplace, write_plugin_marketplace_for_generation, +}; +use host::{ + CommandRunner, RealCommandRunner, host_registration_report, require_host_cli, require_relay, + run_host_marketplace_registration, run_host_marketplace_removal, run_host_plugin_registration, + run_host_plugin_removal, validate_relay_hook_forward, validate_relay_mcp, +}; +use setup::{ + HostPluginSetupRunner, PluginSetupRunner, run_plugin_doctor_json, + run_plugin_doctor_with_generation, run_plugin_setup_with_generation, run_plugin_uninstall, +}; +#[cfg(test)] +use setup::{run_plugin_doctor, run_plugin_setup}; +use state::{ + CanonicalizeOrSelf, HostRegistrationProgress, PluginInstallOptions, PluginLayout, PluginState, + default_install_dir, mark_plugin_setup_installed, read_state, remove_path, state_path, + write_state, write_state_for_host, +}; + +pub(super) use crate::bootstrap::DEFAULT_URL as DEFAULT_GATEWAY_URL; +pub(super) const MARKETPLACE_NAME: &str = "nemo-relay-local"; +pub(super) const PLUGIN_NAME: &str = "nemo-relay-plugin"; +pub(super) const RELAY_COMMAND: &str = "nemo-relay"; + +fn default_operation_lock_dir() -> Result { + std::env::var_os("HOME") + .or_else(|| std::env::var_os("USERPROFILE")) + .map(PathBuf::from) + .map(CanonicalizeOrSelf::canonicalize_or_self) + .map(|home| home.join(".nemo-relay").join("plugin-operations")) + .ok_or_else(|| { + "cannot determine the per-user plugin operation lock directory; set HOME or USERPROFILE" + .into() + }) +} + +/// One non-mutating readiness check for an installed coding-agent plugin. +/// +/// This is deliberately independent from the CLI doctor's status type so the installer can +/// expose its checks to both the focused and top-level doctor paths without coupling their +/// rendering concerns. +#[derive(Debug, Clone, Serialize)] +pub(crate) struct HostPluginReadinessCheck { + pub(crate) name: String, + pub(crate) ok: bool, + pub(crate) details: String, +} + +/// Readiness state for one persistent coding-agent integration. +#[derive(Debug, Clone, Serialize)] +pub(crate) struct HostPluginReadiness { + pub(crate) host: String, + pub(crate) remediation: String, + pub(crate) state_path: PathBuf, + pub(crate) marketplace: Option, + pub(crate) plugin: Option, + pub(crate) checks: Vec, + #[serde(skip_serializing)] + pub(crate) relay: Option, + #[serde(skip_serializing)] + pub(crate) host_plugin_registered: Option, + #[serde(skip_serializing)] + pub(crate) host_marketplace_registered: Option, + #[serde(skip_serializing)] + pub(crate) plugin_setup: Option, +} + +impl HostPluginReadiness { + pub(crate) fn ok(&self) -> bool { + self.checks.iter().all(|check| check.ok) + } + + pub(crate) fn push(&mut self, name: impl Into, result: Result) { + match result { + Ok(details) => self.checks.push(HostPluginReadinessCheck { + name: name.into(), + ok: true, + details, + }), + Err(details) => self.checks.push(HostPluginReadinessCheck { + name: name.into(), + ok: false, + details, + }), + } + } +} + +pub(crate) fn marketplace_state_path(host: impl MarketplaceHost, install_dir: &Path) -> PathBuf { + state_path(host, install_dir) +} + +pub(crate) fn marketplace_install_roots( + host: impl MarketplaceHost, + install_dir: &Path, +) -> (PathBuf, PathBuf) { + let layout = PluginLayout::new(host, install_dir); + (layout.marketplace_root, layout.plugin_root) +} + +pub(crate) fn collect_marketplace_readiness( + host: impl MarketplaceHost, + options: &PluginInstallOptions, + runner: &dyn CommandRunner, +) -> HostPluginReadiness { + let setup_runner = HostPluginSetupRunner::new(host); + collect_host_plugin_readiness(host, options, runner, &setup_runner) +} + +pub(crate) fn install( + host: impl MarketplaceHost, + command: InstallRequest, +) -> Result { + let operation_lock_dir = if command.dry_run { + PathBuf::new() + } else { + default_operation_lock_dir().map_err(CliError::Install)? + }; + let options = PluginInstallOptions { + install_dir: command + .install_dir + .unwrap_or_else(default_install_dir) + .canonicalize_or_self(), + operation_lock_dir, + force: command.force, + dry_run: command.dry_run, + skip_doctor: command.skip_doctor, + }; + run_for_host(host, &options, install_host) +} + +pub(crate) fn uninstall( + host: impl MarketplaceHost, + command: UninstallRequest, +) -> Result { + let operation_lock_dir = if command.dry_run { + PathBuf::new() + } else { + default_operation_lock_dir().map_err(CliError::Install)? + }; + let options = PluginInstallOptions { + install_dir: command + .install_dir + .unwrap_or_else(default_install_dir) + .canonicalize_or_self(), + operation_lock_dir, + force: false, + dry_run: command.dry_run, + skip_doctor: true, + }; + run_for_host(host, &options, uninstall_host) +} + +pub(crate) fn plugin_doctor_options(install_dir: Option) -> PluginInstallOptions { + PluginInstallOptions { + install_dir: install_dir + .unwrap_or_else(default_install_dir) + .canonicalize_or_self(), + operation_lock_dir: PathBuf::new(), + force: false, + dry_run: false, + skip_doctor: true, + } +} + +pub(crate) fn doctor_marketplace_integration( + host: impl MarketplaceHost, + options: &PluginInstallOptions, +) -> Result<(), CliError> { + run_for_host(host, options, doctor_host)?; + Ok(()) +} + +fn run_for_host( + host: H, + options: &PluginInstallOptions, + mut action: F, +) -> Result +where + H: MarketplaceHost, + F: FnMut( + H, + &PluginInstallOptions, + &dyn CommandRunner, + &dyn PluginSetupRunner, + ) -> Result<(), String>, +{ + let runner = RealCommandRunner; + let setup_runner = HostPluginSetupRunner::new(host); + action(host, options, &runner, &setup_runner).map_err(CliError::Install)?; + Ok(ExitCode::SUCCESS) +} + +pub(crate) fn doctor_marketplace_report( + host: impl MarketplaceHost, + options: &PluginInstallOptions, +) -> Result { + let runner = RealCommandRunner; + let setup_runner = HostPluginSetupRunner::new(host); + doctor_host_json_value(host, options, &runner, &setup_runner).map_err(CliError::Install) +} + +pub(crate) fn default_marketplace_install_dir() -> PathBuf { + default_install_dir().canonicalize_or_self() +} + +pub(crate) fn persisted_state_exists(host: impl MarketplaceHost, install_dir: &Path) -> bool { + state_path(host, install_dir).exists() +} + +fn install_host( + host: impl MarketplaceHost, + options: &PluginInstallOptions, + runner: &dyn CommandRunner, + setup_runner: &dyn PluginSetupRunner, +) -> Result<(), String> { + install_host_with_operation_timeout( + host, + options, + runner, + setup_runner, + DEFAULT_OPERATION_LOCK_TIMEOUT, + ) +} + +fn install_host_with_operation_timeout( + host: impl MarketplaceHost, + options: &PluginInstallOptions, + runner: &dyn CommandRunner, + setup_runner: &dyn PluginSetupRunner, + lock_timeout: Duration, +) -> Result<(), String> { + let _operation_lock = (!options.dry_run) + .then(|| { + PluginOperationLock::acquire( + host.install_arg(), + &options.operation_lock_dir, + &options.install_dir, + lock_timeout, + ) + }) + .transpose()?; + install_host_locked(host, options, runner, setup_runner) +} + +fn install_host_locked( + host: impl MarketplaceHost, + options: &PluginInstallOptions, + runner: &dyn CommandRunner, + setup_runner: &dyn PluginSetupRunner, +) -> Result<(), String> { + let relay = require_relay(options, runner)?; + validate_relay_hook_forward(&relay, options, runner)?; + validate_relay_mcp(&relay, options, runner)?; + require_host_cli(host, options, runner)?; + host::validate_host_version(host, options, runner)?; + let layout = PluginLayout::new(host, &options.install_dir); + let plugin_preflight = if !options.dry_run { + Some(prepare_plugin_install(host, &layout, options, runner)?) + } else { + None + }; + if !options.force + && plugin_preflight + .as_ref() + .is_some_and(|preflight| preflight.previous_install_exists) + { + return Err(existing_plugin_install_requires_force_error(host)); + } + let mut force_snapshot = None; + let mut replacement_generation_lock = None; + let staged = if options.force && !options.dry_run { + let preflight = plugin_preflight.expect("MCP plugin force install has preflight state"); + let initialize_generation_lock = match preflight.generation_retirement.as_ref() { + Some(retirement) => !retirement.uses_lock_path(&layout.generation_lock)?, + None => true, + }; + let staged = + stage_plugin_marketplace(host, &relay, &layout, initialize_generation_lock, options)?; + if initialize_generation_lock { + replacement_generation_lock = match acquire_replacement_generation_lock( + host, + &staged.layout.generation_fence, + &layout.generation_lock, + staged.generation_lock_created, + ) { + Ok(lock) => Some(lock), + Err(error) => { + staged.cleanup(); + if staged.generation_lock_created { + remove_generation_lock_best_effort(&layout.generation_lock); + } + return Err(error); + } + }; + } + match begin_force_replacement(host, &layout, preflight, options, runner, setup_runner) { + Ok(mut snapshot) => { + if let Err(error) = setup_runner.refresh_gateway() { + staged.cleanup(); + return restore_force_replacement_after_error( + host, + &layout, + &mut snapshot, + options, + runner, + setup_runner, + error, + ); + } + force_snapshot = Some(snapshot); + Some(staged) + } + Err(error) => { + staged.cleanup(); + return Err(error); + } + } + } else { + None + }; + if options.force && staged.is_none() { + force_cleanup_existing_install(host, &layout, options, runner, setup_runner)?; + } + if let Some(staged) = staged.as_ref() { + if let Err(error) = staged.promote(&layout) { + staged.cleanup(); + return restore_force_replacement_after_error( + host, + &layout, + force_snapshot.as_mut().expect("force snapshot exists"), + options, + runner, + setup_runner, + error, + ); + } + force_snapshot + .as_mut() + .expect("force snapshot exists") + .replacement_promoted = true; + if let Some(lock) = replacement_generation_lock.as_mut() + && let Err(error) = lock.retarget_promoted_marker(&layout.generation_fence) + { + staged.cleanup(); + return restore_force_replacement_after_error( + host, + &layout, + force_snapshot.as_mut().expect("force snapshot exists"), + options, + runner, + setup_runner, + error, + ); + } + staged.cleanup(); + } else { + let generation_lock_created = + !options.dry_run && generation_lock_is_absent(&layout.generation_lock); + if let Err(error) = write_plugin_marketplace(host, &layout, &relay, options) { + let cleanup_error = (!options.dry_run) + .then(|| remove_path(&layout.marketplace_root, options).err()) + .flatten(); + if generation_lock_created { + remove_generation_lock_best_effort(&layout.generation_lock); + } + return match cleanup_error { + Some(cleanup_error) => Err(format!( + "{error}; additionally failed to remove the incomplete marketplace: {cleanup_error}" + )), + None => Err(error), + }; + } + if !options.dry_run { + replacement_generation_lock = match acquire_replacement_generation_lock( + host, + &layout.generation_fence, + &layout.generation_lock, + generation_lock_created, + ) { + Ok(lock) => Some(lock), + Err(error) => { + let cleanup_error = remove_path(&layout.marketplace_root, options).err(); + if generation_lock_created { + remove_generation_lock_best_effort(&layout.generation_lock); + } + return match cleanup_error { + Some(cleanup_error) => Err(format!( + "{error}; additionally failed to remove the incomplete marketplace: {cleanup_error}" + )), + None => Err(error), + }; + } + }; + } + } + if let Err(error) = write_state(&layout, options) { + let _replacement_retirement = if force_snapshot.is_some() { + let existing_retirement = replacement_generation_lock + .as_mut() + .map(ReplacementGenerationLock::retirement_mut) + .or_else(|| { + force_snapshot + .as_mut() + .and_then(|snapshot| snapshot.generation_retirement.as_mut()) + }); + match retire_replacement_before_rollback( + host, + &layout, + options, + setup_runner, + existing_retirement, + ) { + Ok(retirement) => retirement, + Err(retirement_error) => { + return Err(format!( + "{error}; refusing destructive rollback because the replacement MCP generation could not be retired: {retirement_error}" + )); + } + } + } else { + None + }; + let cleanup_error = remove_path(&layout.marketplace_root, options).err(); + let restore_error = force_snapshot.as_mut().and_then(|snapshot| { + restore_force_replacement(host, &layout, snapshot, options, runner, setup_runner).err() + }); + let errors = [cleanup_error, restore_error] + .into_iter() + .flatten() + .collect::>(); + if !errors.is_empty() { + return Err(format!("{error}; additionally {}", errors.join("; "))); + } + return Err(error); + } + let mut registration = HostRegistrationProgress::default(); + let mut registration_state_uncertain = false; + let mut setup_installed = false; + let result = (|| { + let generation_token = replacement_generation_lock + .as_ref() + .map(ReplacementGenerationLock::retirement) + .or_else(|| { + force_snapshot + .as_ref() + .and_then(|snapshot| snapshot.generation_retirement.as_ref()) + }) + .map(GenerationRetirement::active_visible_token) + .transpose()?; + if let Err(error) = + run_host_marketplace_registration(host, &layout.marketplace_root, options, runner) + { + registration_state_uncertain = true; + return Err(error); + } + registration.host_marketplace_added = true; + if let Err(error) = run_host_plugin_registration(host, options, runner) { + registration_state_uncertain = true; + return Err(error); + } + registration.host_plugin_added = true; + if host.setup_may_mutate_before_success() { + setup_installed = true; + } + run_plugin_setup_with_generation( + host, + &layout, + options, + setup_runner, + generation_token.as_deref(), + )?; + setup_installed = true; + mark_plugin_setup_installed(host, &layout, options)?; + if !options.skip_doctor { + run_plugin_doctor_with_generation( + host, + &layout.plugin_root, + options, + setup_runner, + generation_token.as_deref(), + )?; + } + Ok(()) + })(); + if let Err(error) = result { + if registration_state_uncertain { + let observed = host_registration_report(host, options, runner).map_err(|report_error| { + format!( + "{error}; refusing destructive rollback because the host registration state could not be verified after a registration command failed: {report_error}" + ) + })?; + registration.host_plugin_added |= observed.host_plugin_registered; + registration.host_marketplace_added |= observed.host_marketplace_registered; + } + let replacement_may_be_live = force_snapshot.is_some() || registration.host_plugin_added; + let _replacement_retirement = if replacement_may_be_live { + let existing_retirement = replacement_generation_lock + .as_mut() + .map(ReplacementGenerationLock::retirement_mut) + .or_else(|| { + force_snapshot + .as_mut() + .and_then(|snapshot| snapshot.generation_retirement.as_mut()) + }); + match retire_replacement_before_rollback( + host, + &layout, + options, + setup_runner, + existing_retirement, + ) { + Ok(retirement) => retirement, + Err(retirement_error) => { + return Err(format!( + "{error}; refusing destructive rollback because the replacement MCP generation could not be retired: {retirement_error}" + )); + } + } + } else { + None + }; + let rollback_error = rollback_install( + host, + &layout, + registration, + setup_installed, + options, + runner, + setup_runner, + ) + .err(); + let restore_error = force_snapshot.as_mut().and_then(|snapshot| { + restore_force_replacement(host, &layout, snapshot, options, runner, setup_runner).err() + }); + let rollback_errors = [ + rollback_error.map(|error| format!("failed to roll back install: {error}")), + restore_error.map(|error| format!("failed to restore previous install: {error}")), + ] + .into_iter() + .flatten() + .collect::>(); + if !rollback_errors.is_empty() { + return Err(format!( + "{error}; additionally {}", + rollback_errors.join("; ") + )); + } + return Err(error); + } + if let Some(snapshot) = force_snapshot { + snapshot.commit(&layout.generation_lock); + } + println!( + "installed {} plugin marketplace at {}", + host.label(), + layout.marketplace_root.display() + ); + Ok(()) +} + +fn uninstall_host( + host: impl MarketplaceHost, + options: &PluginInstallOptions, + runner: &dyn CommandRunner, + setup_runner: &dyn PluginSetupRunner, +) -> Result<(), String> { + uninstall_host_with_operation_timeout( + host, + options, + runner, + setup_runner, + DEFAULT_OPERATION_LOCK_TIMEOUT, + ) +} + +fn uninstall_host_with_operation_timeout( + host: impl MarketplaceHost, + options: &PluginInstallOptions, + runner: &dyn CommandRunner, + setup_runner: &dyn PluginSetupRunner, + lock_timeout: Duration, +) -> Result<(), String> { + let _operation_lock = (!options.dry_run) + .then(|| { + PluginOperationLock::acquire( + host.install_arg(), + &options.operation_lock_dir, + &options.install_dir, + lock_timeout, + ) + }) + .transpose()?; + uninstall_host_locked(host, options, runner, setup_runner) +} + +fn uninstall_host_locked( + host: impl MarketplaceHost, + options: &PluginInstallOptions, + runner: &dyn CommandRunner, + setup_runner: &dyn PluginSetupRunner, +) -> Result<(), String> { + let state = read_state(host, &options.install_dir); + let layout = PluginLayout::new(host, &options.install_dir); + if let Some(state) = state.as_ref() { + layout.validate_persisted_state(state)?; + } + let plugin_root = state + .as_ref() + .map(|state| state.plugin_root.as_path()) + .unwrap_or(&layout.plugin_root); + let local_install_exists = state.is_some() || layout.marketplace_root.exists(); + let mut generation_retirement = retire_installed_generation( + host, + plugin_root, + &layout.generation_lock, + local_install_exists, + options, + runner, + )?; + if let Some(retirement) = generation_retirement.as_mut() { + retirement.invalidate_for_replacement().map_err(|error| { + format!( + "failed to retire installed MCP generation before uninstalling {}: {error}", + plugin_root.display() + ) + })?; + } + if !options.dry_run + && let Err(error) = setup_runner.refresh_gateway() + { + if let Some(retirement) = generation_retirement.as_mut() + && let Err(restore_error) = retirement.restore_after_rollback() + { + return Err(format!( + "{error}; additionally failed to restore the installed MCP generation: {restore_error}" + )); + } + return Err(error); + } + let retired_lock = generation_retirement + .as_ref() + .map(|retirement| retirement.lock_path().to_owned()); + if let Some(retirement) = generation_retirement.as_mut() { + retirement.release_legacy_lock_for_tree_mutation()?; + retirement.commit_replacement(); + } + let result = uninstall_host_with_setup_override(host, options, runner, setup_runner, false); + if result.is_ok() { + drop(generation_retirement); + if let Some(lock_path) = retired_lock { + remove_generation_lock_best_effort(&lock_path); + } + } + result +} + +fn retire_installed_generation( + host: impl MarketplaceHost, + plugin_root: &Path, + expected_generation_lock: &Path, + local_install_exists: bool, + options: &PluginInstallOptions, + runner: &dyn CommandRunner, +) -> Result, String> { + if options.dry_run { + return Ok(None); + } + let generation_fence = plugin_root.join(GENERATION_FILE_NAME); + let mut existing_install = local_install_exists; + if !generation_fence.exists() { + let registration = host_registration_report(host, options, runner)?; + existing_install |= + registration.host_plugin_registered || registration.host_marketplace_registered; + if existing_install && !legacy_plugin_without_mcp(host, plugin_root)? { + return Err(missing_generation_fence_error(host, &generation_fence)); + } + } + let retirement = + GenerationRetirement::acquire_for_plugin(&generation_fence, expected_generation_lock) + .map_err(|cause| invalid_generation_fence_error(host, &generation_fence, &cause))?; + if retirement.is_none() && !existing_install { + let registration = host_registration_report(host, options, runner)?; + existing_install = + registration.host_plugin_registered || registration.host_marketplace_registered; + } + if retirement.is_none() && existing_install && !legacy_plugin_without_mcp(host, plugin_root)? { + return Err(missing_generation_fence_error(host, &generation_fence)); + } + Ok(retirement) +} + +fn retire_replacement_before_rollback( + host: impl MarketplaceHost, + layout: &PluginLayout, + options: &PluginInstallOptions, + setup_runner: &dyn PluginSetupRunner, + existing_retirement: Option<&mut GenerationRetirement>, +) -> Result, String> { + if options.dry_run { + return Ok(None); + } + if let Some(retirement) = existing_retirement + && retirement.uses_lock_path(&layout.generation_lock)? + { + let visible = retirement.retire_visible_replacement().map_err(|error| { + format!( + "failed to retire replacement MCP generation {} before rollback: {error}", + layout.generation_fence.display() + ) + })?; + if let Err(error) = setup_runner.refresh_gateway() { + return match retirement.restore_visible_replacement(visible) { + Ok(()) => Err(error), + Err(restore_error) => Err(format!( + "{error}; additionally failed to restore the replacement MCP generation after rollback refresh failed: {restore_error}" + )), + }; + } + return Ok(None); + } + let mut retirement = + GenerationRetirement::acquire_for_plugin(&layout.generation_fence, &layout.generation_lock) + .map_err(|cause| { + invalid_generation_fence_error(host, &layout.generation_fence, &cause) + })? + .ok_or_else(|| missing_generation_fence_error(host, &layout.generation_fence))?; + retirement.invalidate_for_replacement().map_err(|error| { + format!( + "failed to retire replacement MCP generation {} before rollback: {error}", + layout.generation_fence.display() + ) + })?; + if let Err(error) = setup_runner.refresh_gateway() { + return match retirement.restore_after_rollback() { + Ok(()) => Err(error), + Err(restore_error) => Err(format!( + "{error}; additionally failed to restore the replacement MCP generation after rollback refresh failed: {restore_error}" + )), + }; + } + retirement.commit_replacement(); + Ok(Some(retirement)) +} + +fn existing_plugin_install_requires_force_error(host: impl MarketplaceHost) -> String { + format!( + "an existing fenced {} plugin install was found; rerun `nemo-relay install {} --force` to replace it safely", + host.label(), + host.install_arg() + ) +} + +fn missing_generation_fence_error(host: impl MarketplaceHost, generation_fence: &Path) -> String { + unsafe_generation_fence_error( + host, + &format!("is missing at {}", generation_fence.display()), + ) +} + +fn invalid_generation_fence_error( + host: impl MarketplaceHost, + generation_fence: &Path, + cause: &str, +) -> String { + unsafe_generation_fence_error( + host, + &format!( + "at {} is invalid or unreadable: {cause}", + generation_fence.display() + ), + ) +} + +fn unsafe_generation_fence_error(host: impl MarketplaceHost, problem: &str) -> String { + host.unsafe_generation_fence_error(problem) +} + +fn legacy_plugin_without_mcp( + host: impl MarketplaceHost, + plugin_root: &Path, +) -> Result { + if !host.accepts_legacy_hook_only_plugin() || plugin_root.join(".mcp.json").exists() { + return Ok(false); + } + let manifest_path = plugin_manifest_path(host, plugin_root); + let raw = match fs::read_to_string(&manifest_path) { + Ok(raw) => raw, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false), + Err(error) => { + return Err(format!( + "failed to inspect legacy plugin manifest {}: {error}", + manifest_path.display() + )); + } + }; + let manifest = serde_json::from_str::(&raw).map_err(|error| { + format!( + "failed to inspect legacy plugin manifest {}: {error}", + manifest_path.display() + ) + })?; + Ok(manifest.get("mcpServers").is_none()) +} + +fn uninstall_host_with_setup_override( + host: impl MarketplaceHost, + options: &PluginInstallOptions, + runner: &dyn CommandRunner, + setup_runner: &dyn PluginSetupRunner, + force_plugin_setup_uninstall: bool, +) -> Result<(), String> { + let layout = PluginLayout::new(host, &options.install_dir); + let state = read_state(host, &options.install_dir).unwrap_or_else(|| PluginState { + marketplace_root: layout.marketplace_root.clone(), + plugin_root: layout.plugin_root.clone(), + host_plugin_removed: false, + host_marketplace_removed: false, + plugin_setup_installed: true, + }); + layout.validate_persisted_state(&state)?; + if let Err(error) = require_relay(options, runner) + .and_then(|relay| validate_relay_hook_forward(&relay, options, runner)) + { + eprintln!("warning: skipping nemo-relay validation during uninstall: {error}"); + } + let mut state = state; + if force_plugin_setup_uninstall && !state.plugin_setup_installed { + state.plugin_setup_installed = true; + write_state_for_host(host, &state, &options.install_dir, options)?; + } + if force_plugin_setup_uninstall || state.plugin_setup_installed { + run_plugin_uninstall(host, &state.plugin_root, options, setup_runner)?; + state.plugin_setup_installed = false; + write_state_for_host(host, &state, &options.install_dir, options)?; + } + run_host_unregistration(host, &mut state, &options.install_dir, options, runner)?; + remove_path(&state.marketplace_root, options)?; + remove_path(&state_path(host, &options.install_dir), options)?; + println!("uninstalled {} plugin", host.label()); + Ok(()) +} + +fn doctor_host( + host: impl MarketplaceHost, + options: &PluginInstallOptions, + runner: &dyn CommandRunner, + setup_runner: &dyn PluginSetupRunner, +) -> Result<(), String> { + let readiness = collect_host_plugin_readiness(host, options, runner, setup_runner); + println!("host: {}", readiness.host); + println!("state: {}", readiness.state_path.display()); + if let Some(path) = &readiness.marketplace { + println!("marketplace: {}", path.display()); + } + if let Some(path) = &readiness.plugin { + println!("plugin: {}", path.display()); + } + for check in &readiness.checks { + let marker = if check.ok { "ok" } else { "failed" }; + println!("{}: {marker} ({})", check.name, check.details); + } + readiness.ok().then_some(()).ok_or_else(|| { + format!( + "{} plugin doctor checks failed; remediation: {}", + host.label(), + readiness.remediation + ) + }) +} + +fn doctor_host_json_value( + host: impl MarketplaceHost, + options: &PluginInstallOptions, + runner: &dyn CommandRunner, + setup_runner: &dyn PluginSetupRunner, +) -> Result { + let readiness = collect_host_plugin_readiness(host, options, runner, setup_runner); + let host_registration_ok = readiness.host_plugin_registered == Some(true) + && readiness.host_marketplace_registered == Some(true); + Ok(json!({ + "ok": readiness.ok(), + "host": readiness.host, + "remediation": readiness.remediation, + "nemo_relay": readiness.relay, + "marketplace": readiness.marketplace, + "plugin": readiness.plugin, + "host_registration": { + "ok": host_registration_ok, + "host_plugin_registered": readiness.host_plugin_registered, + "host_marketplace_registered": readiness.host_marketplace_registered + }, + "checks": readiness.plugin_setup, + "state_path": readiness.state_path, + "readiness_checks": readiness.checks + })) +} + +fn collect_host_plugin_readiness( + host: impl MarketplaceHost, + options: &PluginInstallOptions, + runner: &dyn CommandRunner, + setup_runner: &dyn PluginSetupRunner, +) -> HostPluginReadiness { + let state_path = state_path(host, &options.install_dir); + let state = read_state(host, &options.install_dir); + let layout = PluginLayout::new(host, &options.install_dir); + let setup_plugin_root = state + .as_ref() + .map(|state| state.plugin_root.clone()) + .unwrap_or_else(|| layout.plugin_root.clone()); + let marketplace = state + .as_ref() + .map(|state| state.marketplace_root.clone()) + .or_else(|| state_path.exists().then(|| layout.marketplace_root.clone())); + let plugin = state + .as_ref() + .map(|state| state.plugin_root.clone()) + .or_else(|| state_path.exists().then(|| layout.plugin_root.clone())); + let mut readiness = HostPluginReadiness { + host: host.install_arg().to_string(), + remediation: format!("nemo-relay install {} --force", host.install_arg()), + state_path: state_path.clone(), + marketplace, + plugin, + checks: Vec::new(), + relay: None, + host_plugin_registered: None, + host_marketplace_registered: None, + plugin_setup: None, + }; + + readiness.push( + "Install state", + state + .as_ref() + .map(|_| format!("valid state at {}", state_path.display())) + .ok_or_else(|| format!("missing or invalid state at {}", state_path.display())), + ); + if let Some(marketplace) = readiness.marketplace.as_ref() { + let manifest = marketplace_manifest_path(host, marketplace); + readiness.push( + "Generated marketplace", + generated_manifest_check(&manifest, &marketplace_manifest(host), "marketplace"), + ); + } + if let Some(plugin) = readiness.plugin.as_ref() { + let manifest = plugin_manifest_path(host, plugin); + readiness.push( + "Generated plugin", + generated_manifest_check(&manifest, &plugin_manifest(host), "plugin"), + ); + } + + let relay = require_relay(options, runner); + readiness.push( + "Relay binary", + relay + .as_ref() + .map(|path| format!("found at {}", path.display())) + .map_err(Clone::clone), + ); + if let Ok(relay) = relay { + readiness.relay = Some(relay.clone()); + readiness.push( + "Relay hook support", + validate_relay_hook_forward(&relay, options, runner) + .map(|_| "hook-forward is supported".into()), + ); + if let Some(plugin) = readiness.plugin.as_ref() { + let generation_fence = + plugin.join(crate::installation::generation::GENERATION_FILE_NAME); + readiness.push( + "Generated hooks", + InstallGeneration::capture(generation_fence.clone()).and_then(|generation| { + let expected = + plugin_hooks(host, &relay, &generation_fence, generation.token())?; + generated_manifest_check( + &plugin.join("hooks").join("hooks.json"), + &expected, + "hooks", + ) + }), + ); + } + readiness.push( + "Relay MCP support", + validate_relay_mcp(&relay, options, runner) + .map(|_| "native mcp subcommand is supported".into()), + ); + if let Some(plugin) = readiness.plugin.as_ref() { + let generation_fence = + plugin.join(crate::installation::generation::GENERATION_FILE_NAME); + let mcp_config = plugin_mcp_config_path(plugin); + readiness.push( + "MCP generation fence", + InstallGeneration::capture(generation_fence.clone()) + .map(|_| format!("valid generation at {}", generation_fence.display())), + ); + let check = + InstallGeneration::capture(generation_fence.clone()).and_then(|generation| { + plugin_mcp_config(host, &relay, &generation_fence, generation.token()).and_then( + |expected| generated_mcp_config_check(host, &mcp_config, &expected), + ) + }); + readiness.push("Generated MCP server", check); + } + } + + let host_cli_check = require_host_cli(host, options, runner); + readiness.push( + "Host CLI", + host_cli_check + .as_ref() + .map(|_| format!("{} is available", host.executable())) + .map_err(Clone::clone), + ); + if host_cli_check.is_ok() { + let agent = host; + let version = host::validate_host_version(host, options, runner); + if version.is_err() { + readiness.remediation = format!( + "upgrade to {}, then run `nemo-relay install {} --force`", + agent.version_requirement(), + host.install_arg() + ); + } + readiness.push( + format!("{} version", agent.label()), + version.map(|_| format!("{} is installed", agent.version_requirement())), + ); + match host_registration_report(host, options, runner) { + Ok(report) => { + readiness.host_plugin_registered = Some(report.host_plugin_registered); + readiness.host_marketplace_registered = Some(report.host_marketplace_registered); + readiness.push( + "Host registration", + report + .ok() + .then_some("plugin and marketplace registered".into()) + .ok_or_else(|| "plugin or marketplace registration is incomplete".into()), + ); + readiness.push( + "Host plugin registration", + report + .host_plugin_registered + .then_some("registered".into()) + .ok_or_else(|| "nemo-relay host plugin is not registered".into()), + ); + readiness.push( + "Host marketplace registration", + report + .host_marketplace_registered + .then_some("registered".into()) + .ok_or_else(|| "nemo-relay marketplace is not registered".into()), + ); + } + Err(error) => readiness.push("Host registration", Err(error)), + } + } + + match run_plugin_doctor_json(host, &setup_plugin_root, setup_runner) { + Ok(plugin_report) => { + append_plugin_setup_checks(&mut readiness, &plugin_report); + readiness.plugin_setup = Some(plugin_report); + } + Err(error) => readiness.push("Host setup", Err(error)), + } + readiness +} + +fn append_plugin_setup_checks(readiness: &mut HostPluginReadiness, report: &Value) { + if let Some(health) = report.get("sidecar_health").and_then(Value::as_str) { + readiness.push("Sidecar health", Ok(health.to_string())); + } + if let Some(checks) = report.get("checks").and_then(Value::as_object) { + for (name, value) in checks { + if name == "sidecar_running" { + continue; + } + let details = name.replace('_', " "); + readiness.push( + details, + value + .as_bool() + .filter(|ok| *ok) + .map(|_| "configured".into()) + .ok_or_else(|| "not configured".into()), + ); + } + } +} + +fn without_version(mut value: Value) -> Value { + if let Some(object) = value.as_object_mut() { + object.remove("version"); + } + value +} + +fn generated_manifest_check(path: &Path, expected: &Value, label: &str) -> Result { + let raw = std::fs::read_to_string(path).map_err(|error| { + format!( + "missing or unreadable {label} manifest {}: {error}", + path.display() + ) + })?; + let actual = serde_json::from_str::(&raw) + .map_err(|error| format!("invalid {label} manifest {}: {error}", path.display()))?; + if without_version(actual) == without_version(expected.clone()) { + Ok(format!("valid at {}", path.display())) + } else { + Err(format!( + "unexpected {label} manifest contents at {}", + path.display() + )) + } +} + +fn generated_mcp_config_check( + host: impl MarketplaceHost, + path: &Path, + expected: &Value, +) -> Result { + generated_mcp_config_check_for_platform(host, path, expected, cfg!(windows)) +} + +fn generated_mcp_config_check_for_platform( + host: impl MarketplaceHost, + path: &Path, + expected: &Value, + windows: bool, +) -> Result { + let raw = std::fs::read_to_string(path).map_err(|error| { + format!( + "missing or unreadable MCP server manifest {}: {error}", + path.display() + ) + })?; + let actual = serde_json::from_str::(&raw) + .map_err(|error| format!("invalid MCP server manifest {}: {error}", path.display()))?; + if actual == *expected { + return Ok(format!("valid at {}", path.display())); + } + if !host.accepts_mcp_environment_superset() { + return Err(format!( + "unexpected MCP server manifest contents at {}; run `nemo-relay install {} --force`", + path.display(), + host.install_arg() + )); + } + let expected_server = &expected["nemo-relay"]; + let actual_server = &actual["nemo-relay"]; + let Some(expected_vars) = mcp_env_var_names(expected_server) else { + return Err(format!( + "unexpected MCP server manifest contents at {}; run `nemo-relay install {} --force`", + path.display(), + host.install_arg() + )); + }; + let Some(actual_vars) = mcp_env_var_names(actual_server) else { + return Err(format!( + "unexpected MCP server manifest contents at {}; run `nemo-relay install {} --force`", + path.display(), + host.install_arg() + )); + }; + let duplicate = actual_vars.iter().enumerate().any(|(index, name)| { + actual_vars[..index].iter().any(|other| { + crate::mcp_environment::forwarded_names_match_for_platform(name, other, windows) + }) + }); + if duplicate + || actual_vars.iter().any(|name| { + !expected_vars.iter().any(|expected| { + crate::mcp_environment::forwarded_names_match_for_platform(name, expected, windows) + }) && !crate::mcp_environment::previously_forwardable_name_for_platform(name, windows) + }) + { + return Err(format!( + "unexpected MCP server manifest contents at {}; run `nemo-relay install {} --force`", + path.display(), + host.install_arg() + )); + } + let missing = expected_vars + .iter() + .filter(|expected| { + !actual_vars.iter().any(|actual| { + crate::mcp_environment::forwarded_names_match_for_platform( + expected, actual, windows, + ) + }) + }) + .map(String::as_str) + .collect::>(); + if !missing.is_empty() { + return Err(format!( + "MCP server at {} is missing forwarded environment variables: {}; run `nemo-relay install {} --force`", + path.display(), + missing.join(", "), + host.install_arg() + )); + } + let mut expected_without_vars = expected.clone(); + let mut actual_without_vars = actual.clone(); + let expected_server = expected_without_vars + .get_mut("nemo-relay") + .and_then(Value::as_object_mut); + let actual_server = actual_without_vars + .get_mut("nemo-relay") + .and_then(Value::as_object_mut); + if let (Some(expected_server), Some(actual_server)) = (expected_server, actual_server) { + expected_server.remove("env_vars"); + actual_server.remove("env_vars"); + if actual_without_vars == expected_without_vars { + return Ok(format!("valid at {}", path.display())); + } + } + Err(format!( + "unexpected MCP server manifest contents at {}; run `nemo-relay install {} --force`", + path.display(), + host.install_arg() + )) +} + +fn mcp_env_var_names(server: &Value) -> Option> { + server + .get("env_vars")? + .as_array()? + .iter() + .map(|value| value.as_str().map(str::to_owned)) + .collect() +} + +fn marketplace_manifest_path(host: impl MarketplaceHost, root: &Path) -> PathBuf { + host.marketplace_manifest_relative() + .iter() + .fold(root.to_path_buf(), |path, component| path.join(component)) +} + +fn plugin_manifest_path(host: impl MarketplaceHost, root: &Path) -> PathBuf { + host.plugin_manifest_relative() + .iter() + .fold(root.to_path_buf(), |path, component| path.join(component)) +} + +fn plugin_mcp_config_path(root: &Path) -> PathBuf { + root.join(".mcp.json") +} + +struct StagedPluginMarketplace { + layout: PluginLayout, + parent: PathBuf, + generation_lock_created: bool, +} + +struct ReplacementGenerationLock { + retirement: Option, + lock_path: PathBuf, + remove_lock_if_unreferenced: bool, +} + +fn acquire_replacement_generation_lock( + host: impl MarketplaceHost, + marker_path: &Path, + expected_generation_lock: &Path, + remove_lock_if_unreferenced: bool, +) -> Result { + match GenerationRetirement::acquire_for_plugin(marker_path, expected_generation_lock) { + Ok(Some(retirement)) => Ok(ReplacementGenerationLock::new( + retirement, + remove_lock_if_unreferenced, + )), + Ok(None) => Err(missing_generation_fence_error(host, marker_path)), + Err(cause) => Err(invalid_generation_fence_error(host, marker_path, &cause)), + } +} + +impl ReplacementGenerationLock { + fn new(retirement: GenerationRetirement, remove_lock_if_unreferenced: bool) -> Self { + Self { + lock_path: retirement.lock_path().to_owned(), + retirement: Some(retirement), + remove_lock_if_unreferenced, + } + } + + fn retirement_mut(&mut self) -> &mut GenerationRetirement { + self.retirement + .as_mut() + .expect("replacement generation transaction remains present") + } + + fn retirement(&self) -> &GenerationRetirement { + self.retirement + .as_ref() + .expect("replacement generation transaction remains present") + } + + fn retarget_promoted_marker(&mut self, marker_path: &Path) -> Result<(), String> { + self.retirement_mut().retarget_promoted_marker(marker_path) + } +} + +impl Drop for ReplacementGenerationLock { + fn drop(&mut self) { + let remove_owned_lock = self.remove_lock_if_unreferenced + && self.retirement.as_ref().is_some_and(|retirement| { + match fs::metadata(retirement.marker_path()) { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => true, + Err(error) => { + eprintln!( + "warning: retaining MCP generation lock {} because marker {} could not be inspected: {error}", + self.lock_path.display(), + retirement.marker_path().display() + ); + false + } + Ok(_) => match retirement.visible_marker_uses_transaction_lock() { + Ok(referenced) => !referenced, + Err(error) => { + eprintln!( + "warning: retaining MCP generation lock {} because its marker reference could not be verified: {error}", + self.lock_path.display() + ); + false + } + }, + } + }); + drop(self.retirement.take()); + if remove_owned_lock { + remove_generation_lock_best_effort(&self.lock_path); + } + } +} + +impl StagedPluginMarketplace { + fn promote(&self, target: &PluginLayout) -> Result<(), String> { + fs::rename(&self.layout.marketplace_root, &target.marketplace_root).map_err(|error| { + format!( + "failed to promote staged marketplace {} to {}: {error}", + self.layout.marketplace_root.display(), + target.marketplace_root.display() + ) + }) + } + + fn cleanup(&self) { + let _ = fs::remove_dir_all(&self.parent); + } +} + +struct PluginInstallPreflight { + persisted: Option, + state_bytes: Option>, + previous_marketplace_root: PathBuf, + previous_plugin_root: PathBuf, + previous_generation_fence: PathBuf, + plugin_registered: bool, + marketplace_registered: bool, + previous_setup_installed: bool, + previous_install_exists: bool, + generation_retirement: Option, +} + +fn prepare_plugin_install( + host: impl MarketplaceHost, + layout: &PluginLayout, + options: &PluginInstallOptions, + runner: &dyn CommandRunner, +) -> Result { + let persisted = read_state(host, &options.install_dir); + if let Some(state) = persisted.as_ref() { + layout.validate_persisted_state(state)?; + } + let registration = host_registration_report(host, options, runner)?; + let plugin_registered = registration.host_plugin_registered; + let marketplace_registered = registration.host_marketplace_registered; + let state_bytes = match fs::read(&layout.state_path) { + Ok(bytes) => Some(bytes), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, + Err(error) => { + return Err(format!( + "failed to snapshot {}: {error}", + layout.state_path.display() + )); + } + }; + let previous_setup_installed = persisted + .as_ref() + .is_some_and(|state| state.plugin_setup_installed) + || plugin_registered; + let previous_marketplace_root = persisted + .as_ref() + .map(|state| state.marketplace_root.clone()) + .unwrap_or_else(|| layout.marketplace_root.clone()); + let previous_plugin_root = persisted + .as_ref() + .map(|state| state.plugin_root.clone()) + .unwrap_or_else(|| layout.plugin_root.clone()); + let previous_generation_fence = previous_plugin_root.join(GENERATION_FILE_NAME); + let previous_plugin_manifest = plugin_manifest_path(host, &previous_plugin_root); + let local_install_exists = host.local_install_exists( + &layout.marketplace_root, + &previous_plugin_root, + &previous_plugin_manifest, + &previous_generation_fence, + ); + let previous_install_exists = state_bytes.is_some() + || local_install_exists + || plugin_registered + || marketplace_registered; + let generation_retirement = if previous_install_exists { + if !previous_generation_fence.exists() { + if legacy_plugin_without_mcp(host, &previous_plugin_root)? { + None + } else { + return Err(missing_generation_fence_error( + host, + &previous_generation_fence, + )); + } + } else { + Some( + GenerationRetirement::acquire_for_plugin( + &previous_generation_fence, + &layout.generation_lock, + ) + .map_err(|cause| { + invalid_generation_fence_error(host, &previous_generation_fence, &cause) + })? + .ok_or_else(|| missing_generation_fence_error(host, &previous_generation_fence))?, + ) + } + } else { + None + }; + Ok(PluginInstallPreflight { + persisted, + state_bytes, + previous_marketplace_root, + previous_plugin_root, + previous_generation_fence, + plugin_registered, + marketplace_registered, + previous_setup_installed, + previous_install_exists, + generation_retirement, + }) +} + +struct ForceInstallSnapshot { + state_bytes: Option>, + setup_snapshot: Option, + original_marketplace_root: PathBuf, + original_plugin_root: PathBuf, + original_generation_fence: PathBuf, + plugin_registered: bool, + marketplace_registered: bool, + backup_marketplace_root: PathBuf, + backup_plugin_root: Option, + marketplace_moved: bool, + plugin_moved: bool, + replacement_promoted: bool, + generation_retirement: Option, +} + +impl ForceInstallSnapshot { + fn plugin_moves_with_marketplace(&self) -> bool { + self.original_plugin_root + .starts_with(&self.original_marketplace_root) + } + + fn commit(mut self, replacement_lock: &Path) { + let obsolete_lock = self.generation_retirement.as_ref().and_then(|retirement| { + retirement + .uses_lock_path(replacement_lock) + .ok() + .filter(|same| !same) + .map(|_| retirement.lock_path().to_owned()) + }); + if let Some(retirement) = self.generation_retirement.as_mut() { + retirement.commit_replacement(); + } + if self.marketplace_moved { + match fs::remove_dir_all(&self.backup_marketplace_root) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => eprintln!( + "warning: failed to remove replaced marketplace backup {}: {error}", + self.backup_marketplace_root.display() + ), + } + } + if self.plugin_moved + && let Some(backup_plugin_root) = self.backup_plugin_root.as_ref() + { + match fs::remove_dir_all(backup_plugin_root) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => eprintln!( + "warning: failed to remove replaced plugin backup {}: {error}", + backup_plugin_root.display() + ), + } + } + drop(self.generation_retirement.take()); + if let Some(lock_path) = obsolete_lock { + remove_generation_lock_best_effort(&lock_path); + } + } +} + +fn remove_generation_lock_best_effort(path: &Path) { + match fs::remove_file(path) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => eprintln!( + "warning: failed to remove retired MCP generation lock {}: {error}", + path.display() + ), + } +} + +fn generation_lock_is_absent(path: &Path) -> bool { + matches!( + fs::symlink_metadata(path), + Err(error) if error.kind() == std::io::ErrorKind::NotFound + ) +} + +fn stage_plugin_marketplace( + host: impl MarketplaceHost, + relay: &Path, + target: &PluginLayout, + initialize_generation_lock: bool, + options: &PluginInstallOptions, +) -> Result { + let parent = options.install_dir.join(format!( + ".{}-install-stage-{}", + host.install_arg(), + uuid::Uuid::now_v7() + )); + stage_plugin_marketplace_at( + host, + relay, + target, + initialize_generation_lock, + options, + parent, + ) +} + +fn stage_plugin_marketplace_at( + host: impl MarketplaceHost, + relay: &Path, + target: &PluginLayout, + initialize_generation_lock: bool, + options: &PluginInstallOptions, + parent: PathBuf, +) -> Result { + let layout = PluginLayout::new(host, &parent); + let generation_lock_created = + initialize_generation_lock && generation_lock_is_absent(&target.generation_lock); + if let Err(error) = write_plugin_marketplace_for_generation( + host, + &layout, + relay, + &target.generation_fence, + &target.generation_lock, + initialize_generation_lock, + options, + ) { + let _ = fs::remove_dir_all(&parent); + if generation_lock_created { + remove_generation_lock_best_effort(&target.generation_lock); + } + return Err(error); + } + Ok(StagedPluginMarketplace { + layout, + parent, + generation_lock_created, + }) +} + +fn begin_force_replacement( + host: impl MarketplaceHost, + layout: &PluginLayout, + preflight: PluginInstallPreflight, + options: &PluginInstallOptions, + runner: &dyn CommandRunner, + setup_runner: &dyn PluginSetupRunner, +) -> Result { + let PluginInstallPreflight { + persisted, + state_bytes, + previous_marketplace_root, + previous_plugin_root, + previous_generation_fence, + plugin_registered, + marketplace_registered, + previous_setup_installed, + previous_install_exists: _, + generation_retirement, + } = preflight; + let setup_snapshot = setup_runner.snapshot(host.install_arg())?; + let backup_parent = previous_marketplace_root + .parent() + .unwrap_or(&options.install_dir); + let backup_marketplace_root = backup_parent.join(format!( + ".{}-marketplace-backup-{}", + host.install_arg(), + uuid::Uuid::now_v7() + )); + let backup_plugin_root = + (!previous_plugin_root.starts_with(&previous_marketplace_root)).then(|| { + previous_plugin_root + .parent() + .unwrap_or(&options.install_dir) + .join(format!( + ".{}-plugin-backup-{}", + host.install_arg(), + uuid::Uuid::now_v7() + )) + }); + let mut snapshot = ForceInstallSnapshot { + state_bytes, + setup_snapshot, + original_marketplace_root: previous_marketplace_root, + original_plugin_root: previous_plugin_root, + original_generation_fence: previous_generation_fence, + plugin_registered, + marketplace_registered, + backup_marketplace_root, + backup_plugin_root, + marketplace_moved: false, + plugin_moved: false, + replacement_promoted: false, + generation_retirement, + }; + let mut cleanup_state = persisted.unwrap_or_else(|| PluginState { + marketplace_root: layout.marketplace_root.clone(), + plugin_root: layout.plugin_root.clone(), + host_plugin_removed: !plugin_registered, + host_marketplace_removed: !marketplace_registered, + plugin_setup_installed: previous_setup_installed, + }); + cleanup_state.host_plugin_removed = !plugin_registered; + cleanup_state.host_marketplace_removed = !marketplace_registered; + let result = (|| { + if cleanup_state.plugin_setup_installed { + run_plugin_uninstall(host, &cleanup_state.plugin_root, options, setup_runner)?; + cleanup_state.plugin_setup_installed = false; + } + run_host_unregistration( + host, + &mut cleanup_state, + &options.install_dir, + options, + runner, + ) + })() + .and_then(|()| { + if let Some(retirement) = snapshot.generation_retirement.as_mut() { + retirement.invalidate_for_replacement().map_err(|error| { + format!( + "failed to retire previous MCP generation {} before replacement: {error}", + snapshot.original_generation_fence.display() + ) + })?; + retirement + .release_legacy_lock_for_tree_mutation() + .map_err(|error| { + format!( + "failed to release previous MCP generation {} before moving its plugin tree: {error}", + snapshot.original_generation_fence.display() + ) + })?; + } + if snapshot.original_marketplace_root.exists() { + fs::rename( + &snapshot.original_marketplace_root, + &snapshot.backup_marketplace_root, + ) + .map_err(|error| { + format!( + "failed to preserve existing marketplace {}: {error}", + snapshot.original_marketplace_root.display() + ) + })?; + snapshot.marketplace_moved = true; + } + if !snapshot.plugin_moves_with_marketplace() && snapshot.original_plugin_root.exists() { + let backup_plugin_root = snapshot + .backup_plugin_root + .as_ref() + .expect("separate original plugin root has a backup path"); + fs::rename(&snapshot.original_plugin_root, backup_plugin_root).map_err(|error| { + format!( + "failed to preserve existing plugin root {} containing generation marker {}: {error}", + snapshot.original_plugin_root.display(), + snapshot.original_generation_fence.display() + ) + })?; + snapshot.plugin_moved = true; + } + Ok(()) + }); + if let Err(error) = result { + return restore_force_replacement_after_error( + host, + layout, + &mut snapshot, + options, + runner, + setup_runner, + error, + ); + } + Ok(snapshot) +} + +fn restore_force_replacement_after_error( + host: impl MarketplaceHost, + layout: &PluginLayout, + snapshot: &mut ForceInstallSnapshot, + options: &PluginInstallOptions, + runner: &dyn CommandRunner, + setup_runner: &dyn PluginSetupRunner, + original_error: String, +) -> Result { + match restore_force_replacement(host, layout, snapshot, options, runner, setup_runner) { + Ok(()) => Err(original_error), + Err(rollback_error) => Err(format!( + "{original_error}; additionally failed to restore previous install: {rollback_error}" + )), + } +} + +fn restore_force_replacement( + host: impl MarketplaceHost, + layout: &PluginLayout, + snapshot: &mut ForceInstallSnapshot, + options: &PluginInstallOptions, + runner: &dyn CommandRunner, + setup_runner: &dyn PluginSetupRunner, +) -> Result<(), String> { + let mut errors = Vec::new(); + if snapshot.replacement_promoted { + match host_registration_report(host, options, runner) { + Ok(report) => { + if report.host_plugin_registered + && let Err(error) = run_host_plugin_removal(host, options, runner) + { + errors.push(error); + } + if report.host_marketplace_registered + && let Err(error) = run_host_marketplace_removal(host, options, runner) + { + errors.push(error); + } + } + Err(error) => errors.push(error), + } + if let Err(error) = remove_path(&layout.marketplace_root, options) { + errors.push(error); + } + snapshot.replacement_promoted = false; + } + if snapshot.marketplace_moved { + if let Err(error) = fs::rename( + &snapshot.backup_marketplace_root, + &snapshot.original_marketplace_root, + ) { + errors.push(format!( + "failed to restore marketplace {}: {error}", + snapshot.original_marketplace_root.display() + )); + } else { + snapshot.marketplace_moved = false; + } + } + if snapshot.plugin_moved + && let Some(backup_plugin_root) = snapshot.backup_plugin_root.as_ref() + { + if let Err(error) = fs::rename(backup_plugin_root, &snapshot.original_plugin_root) { + errors.push(format!( + "failed to restore plugin root {} containing generation marker {}: {error}", + snapshot.original_plugin_root.display(), + snapshot.original_generation_fence.display() + )); + } else { + snapshot.plugin_moved = false; + } + } + if let Some(retirement) = snapshot.generation_retirement.as_mut() + && let Err(error) = retirement.restore_after_rollback() + { + errors.push(error); + } + match host_registration_report(host, options, runner) { + Ok(report) => { + if report.host_plugin_registered + && !snapshot.plugin_registered + && let Err(error) = run_host_plugin_removal(host, options, runner) + { + errors.push(error); + } + if report.host_marketplace_registered + && !snapshot.marketplace_registered + && let Err(error) = run_host_marketplace_removal(host, options, runner) + { + errors.push(error); + } + if snapshot.marketplace_registered + && !report.host_marketplace_registered + && let Err(error) = run_host_marketplace_registration( + host, + &snapshot.original_marketplace_root, + options, + runner, + ) + { + errors.push(error); + } + if snapshot.plugin_registered + && !report.host_plugin_registered + && let Err(error) = run_host_plugin_registration(host, options, runner) + { + errors.push(error); + } + } + Err(error) => errors.push(error), + } + if let Some(setup_snapshot) = snapshot.setup_snapshot.as_ref() + && let Err(error) = setup_runner.restore_snapshot(setup_snapshot) + { + errors.push(error); + } + if let Some(bytes) = snapshot.state_bytes.as_deref() { + if let Some(parent) = layout.state_path.parent() + && let Err(error) = fs::create_dir_all(parent) + { + errors.push(format!("failed to create {}: {error}", parent.display())); + } + if let Err(error) = fs::write(&layout.state_path, bytes) { + errors.push(format!( + "failed to restore {}: {error}", + layout.state_path.display() + )); + } + } else if let Err(error) = fs::remove_file(&layout.state_path) + && error.kind() != std::io::ErrorKind::NotFound + { + errors.push(format!( + "failed to remove {}: {error}", + layout.state_path.display() + )); + } + if errors.is_empty() { + Ok(()) + } else { + Err(errors.join("; ")) + } +} + +fn force_cleanup_existing_install( + host: impl MarketplaceHost, + layout: &PluginLayout, + options: &PluginInstallOptions, + runner: &dyn CommandRunner, + setup_runner: &dyn PluginSetupRunner, +) -> Result<(), String> { + if layout.state_path.exists() { + uninstall_host_locked(host, options, runner, setup_runner)?; + } else { + let mut state = PluginState { + marketplace_root: layout.marketplace_root.clone(), + plugin_root: layout.plugin_root.clone(), + host_plugin_removed: false, + host_marketplace_removed: false, + plugin_setup_installed: false, + }; + run_host_unregistration(host, &mut state, &options.install_dir, options, runner)?; + remove_path(&layout.marketplace_root, options)?; + remove_path(&layout.state_path, options)?; + } + Ok(()) +} + +fn rollback_install( + host: impl MarketplaceHost, + layout: &PluginLayout, + registration: HostRegistrationProgress, + setup_installed: bool, + options: &PluginInstallOptions, + runner: &dyn CommandRunner, + setup_runner: &dyn PluginSetupRunner, +) -> Result<(), String> { + if setup_installed { + return uninstall_host_with_setup_override(host, options, runner, setup_runner, true); + } + let mut state = read_state(host, &options.install_dir).unwrap_or_else(|| PluginState { + marketplace_root: layout.marketplace_root.clone(), + plugin_root: layout.plugin_root.clone(), + host_plugin_removed: false, + host_marketplace_removed: false, + plugin_setup_installed: false, + }); + if registration.any_added() { + state.host_plugin_removed |= !registration.host_plugin_added; + state.host_marketplace_removed |= !registration.host_marketplace_added; + write_state_for_host(host, &state, &options.install_dir, options)?; + run_host_unregistration(host, &mut state, &options.install_dir, options, runner)?; + } + remove_path(&layout.marketplace_root, options)?; + remove_path(&layout.state_path, options) +} + +fn run_host_unregistration( + host: impl MarketplaceHost, + state: &mut PluginState, + install_dir: &Path, + options: &PluginInstallOptions, + runner: &dyn CommandRunner, +) -> Result<(), String> { + if !state.host_plugin_removed { + require_host_cli(host, options, runner)?; + run_host_plugin_removal(host, options, runner)?; + state.host_plugin_removed = true; + write_state_for_host(host, state, install_dir, options)?; + } + if !state.host_marketplace_removed { + require_host_cli(host, options, runner)?; + run_host_marketplace_removal(host, options, runner)?; + state.host_marketplace_removed = true; + write_state_for_host(host, state, install_dir, options)?; + } + Ok(()) +} + +#[cfg(test)] +use state::*; + +#[cfg(test)] +#[path = "../../../tests/coverage/agents/plugin_install_tests.rs"] +mod tests; diff --git a/crates/cli/src/installation/marketplace/setup.rs b/crates/cli/src/installation/marketplace/setup.rs new file mode 100644 index 000000000..71dcee1c1 --- /dev/null +++ b/crates/cli/src/installation/marketplace/setup.rs @@ -0,0 +1,222 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Host-bound setup, restore, and doctor delegation. + +use std::path::Path; + +use serde_json::Value; + +use super::state::{PluginInstallOptions, PluginLayout}; +use super::{DEFAULT_GATEWAY_URL, MarketplaceHost, PluginSetupSnapshot}; + +#[cfg(test)] +pub(super) fn run_plugin_setup( + host: impl MarketplaceHost, + layout: &PluginLayout, + options: &PluginInstallOptions, + setup_runner: &dyn PluginSetupRunner, +) -> Result<(), String> { + run_plugin_setup_with_generation(host, layout, options, setup_runner, None) +} + +pub(super) fn run_plugin_setup_with_generation( + host: impl MarketplaceHost, + layout: &PluginLayout, + options: &PluginInstallOptions, + setup_runner: &dyn PluginSetupRunner, + generation_token: Option<&str>, +) -> Result<(), String> { + if options.dry_run { + println!("{}", setup_runner.action_description("configure")); + return Ok(()); + } + setup_runner.setup_with_generation( + host.install_arg(), + DEFAULT_GATEWAY_URL, + &layout.plugin_root, + generation_token, + ) +} + +pub(super) fn run_plugin_uninstall( + host: impl MarketplaceHost, + plugin_root: &Path, + options: &PluginInstallOptions, + setup_runner: &dyn PluginSetupRunner, +) -> Result<(), String> { + if options.dry_run { + println!("{}", setup_runner.action_description("restore")); + return Ok(()); + } + setup_runner.uninstall(host.install_arg(), DEFAULT_GATEWAY_URL, plugin_root) +} + +#[cfg(test)] +pub(super) fn run_plugin_doctor( + host: impl MarketplaceHost, + plugin_root: &Path, + options: &PluginInstallOptions, + setup_runner: &dyn PluginSetupRunner, +) -> Result<(), String> { + run_plugin_doctor_with_generation(host, plugin_root, options, setup_runner, None) +} + +pub(super) fn run_plugin_doctor_with_generation( + host: impl MarketplaceHost, + plugin_root: &Path, + options: &PluginInstallOptions, + setup_runner: &dyn PluginSetupRunner, + generation_token: Option<&str>, +) -> Result<(), String> { + if options.dry_run { + println!("{}", setup_runner.action_description("doctor")); + return Ok(()); + } + setup_runner.doctor_with_generation( + host.install_arg(), + DEFAULT_GATEWAY_URL, + plugin_root, + generation_token, + ) +} + +pub(super) fn run_plugin_doctor_json( + host: impl MarketplaceHost, + plugin_root: &Path, + setup_runner: &dyn PluginSetupRunner, +) -> Result { + setup_runner.doctor_json(host.install_arg(), DEFAULT_GATEWAY_URL, plugin_root) +} + +pub(super) trait PluginSetupRunner { + fn action_description(&self, action: &str) -> String { + action.to_string() + } + + fn snapshot(&self, _host_arg: &str) -> Result, String> { + Ok(None) + } + + fn restore_snapshot(&self, snapshot: &PluginSetupSnapshot) -> Result<(), String> { + snapshot.restore() + } + + fn refresh_gateway(&self) -> Result<(), String> { + Ok(()) + } + + fn setup(&self, host_arg: &str, gateway_url: &str, plugin_root: &Path) -> Result<(), String>; + + fn setup_with_generation( + &self, + host_arg: &str, + gateway_url: &str, + plugin_root: &Path, + _generation_token: Option<&str>, + ) -> Result<(), String> { + self.setup(host_arg, gateway_url, plugin_root) + } + + fn uninstall( + &self, + host_arg: &str, + gateway_url: &str, + plugin_root: &Path, + ) -> Result<(), String>; + + fn doctor(&self, host_arg: &str, gateway_url: &str, plugin_root: &Path) -> Result<(), String>; + + fn doctor_with_generation( + &self, + host_arg: &str, + gateway_url: &str, + plugin_root: &Path, + _generation_token: Option<&str>, + ) -> Result<(), String> { + self.doctor(host_arg, gateway_url, plugin_root) + } + + fn doctor_json( + &self, + host_arg: &str, + gateway_url: &str, + plugin_root: &Path, + ) -> Result; +} + +pub(super) struct HostPluginSetupRunner { + host: H, +} + +impl HostPluginSetupRunner { + pub(super) const fn new(host: H) -> Self { + Self { host } + } +} + +impl PluginSetupRunner for HostPluginSetupRunner { + fn action_description(&self, action: &str) -> String { + self.host.setup_action_description(action) + } + + fn snapshot(&self, _host_arg: &str) -> Result, String> { + self.host.snapshot_setup() + } + + fn refresh_gateway(&self) -> Result<(), String> { + crate::bootstrap::state::stop_owned_and_reset(crate::bootstrap::DEFAULT_URL) + } + + fn setup(&self, _host_arg: &str, gateway_url: &str, plugin_root: &Path) -> Result<(), String> { + self.host.setup_plugin(gateway_url, plugin_root, None) + } + + fn setup_with_generation( + &self, + _host_arg: &str, + gateway_url: &str, + plugin_root: &Path, + generation_token: Option<&str>, + ) -> Result<(), String> { + self.host + .setup_plugin(gateway_url, plugin_root, generation_token) + } + + fn uninstall( + &self, + _host_arg: &str, + gateway_url: &str, + plugin_root: &Path, + ) -> Result<(), String> { + self.host.uninstall_plugin(gateway_url, plugin_root) + } + + fn doctor(&self, _host_arg: &str, gateway_url: &str, plugin_root: &Path) -> Result<(), String> { + self.host.doctor_plugin(gateway_url, plugin_root, None) + } + + fn doctor_with_generation( + &self, + _host_arg: &str, + gateway_url: &str, + plugin_root: &Path, + generation_token: Option<&str>, + ) -> Result<(), String> { + self.host + .doctor_plugin(gateway_url, plugin_root, generation_token) + } + + fn doctor_json( + &self, + _host_arg: &str, + gateway_url: &str, + plugin_root: &Path, + ) -> Result { + self.host.doctor_plugin_json(gateway_url, plugin_root) + } +} + +#[cfg(test)] +#[path = "../../../tests/coverage/agents/plugin_install_setup_tests.rs"] +mod tests; diff --git a/crates/cli/src/installation/marketplace/spec.rs b/crates/cli/src/installation/marketplace/spec.rs new file mode 100644 index 000000000..8fca35a7e --- /dev/null +++ b/crates/cli/src/installation/marketplace/spec.rs @@ -0,0 +1,84 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Agent-owned behavior required by the shared marketplace transaction. + +use std::path::Path; + +use serde_json::Value; + +use super::host::{CommandRunner, HostRegistrationReport}; +use super::state::PluginInstallOptions; + +pub(crate) enum PluginSetupSnapshot { + Callback(Box Result<(), String>>), + #[cfg(test)] + Mock, +} + +impl PluginSetupSnapshot { + pub(crate) fn new(restore: impl Fn() -> Result<(), String> + 'static) -> Self { + Self::Callback(Box::new(restore)) + } + + pub(crate) fn restore(&self) -> Result<(), String> { + match self { + Self::Callback(restore) => restore(), + #[cfg(test)] + Self::Mock => Ok(()), + } + } +} + +pub(crate) trait MarketplaceHost: Copy { + fn install_arg(self) -> &'static str; + fn label(self) -> &'static str; + fn executable(self) -> &'static str; + fn validate_version_output(self, output: &str) -> Result<(), String>; + fn version_requirement(self) -> String; + fn marketplace_manifest_relative(self) -> &'static [&'static str]; + fn plugin_manifest_relative(self) -> &'static [&'static str]; + fn marketplace_manifest(self, marketplace: &str, plugin: &str) -> Value; + fn plugin_manifest(self, plugin: &str) -> Value; + fn plugin_mcp_config(self, server: Value) -> Result; + fn plugin_hooks( + self, + relay: &Path, + generation_fence: &Path, + generation_token: &str, + ) -> Result; + fn plugin_registration_args(self, plugin_id: &str) -> Vec; + fn plugin_removal_args(self, plugin_name: &str, plugin_id: &str) -> Vec; + fn registration_report( + self, + options: &PluginInstallOptions, + runner: &dyn CommandRunner, + ) -> Result; + fn setup_may_mutate_before_success(self) -> bool; + fn unsafe_generation_fence_error(self, problem: &str) -> String; + fn accepts_legacy_hook_only_plugin(self) -> bool; + fn accepts_mcp_environment_superset(self) -> bool; + fn local_install_exists( + self, + marketplace_root: &Path, + plugin_root: &Path, + plugin_manifest: &Path, + generation_fence: &Path, + ) -> bool; + fn setup_action_description(self, action: &str) -> String; + fn snapshot_setup(self) -> Result, String>; + fn setup_plugin( + self, + gateway_url: &str, + plugin_root: &Path, + generation_token: Option<&str>, + ) -> Result<(), String>; + fn uninstall_plugin(self, gateway_url: &str, plugin_root: &Path) -> Result<(), String>; + fn doctor_plugin( + self, + gateway_url: &str, + plugin_root: &Path, + generation_token: Option<&str>, + ) -> Result<(), String>; + fn doctor_plugin_json(self, gateway_url: &str, plugin_root: &Path) -> Result; +} diff --git a/crates/cli/src/plugin_install/state.rs b/crates/cli/src/installation/marketplace/state.rs similarity index 62% rename from crates/cli/src/plugin_install/state.rs rename to crates/cli/src/installation/marketplace/state.rs index 2ceaed749..af87e27db 100644 --- a/crates/cli/src/plugin_install/state.rs +++ b/crates/cli/src/installation/marketplace/state.rs @@ -10,22 +10,17 @@ use std::path::{Path, PathBuf}; use serde_json::{Value, json}; -use crate::config::PluginHost; +use crate::installation::generation::GENERATION_FILE_NAME; -use super::{PLUGIN_NAME, host_arg}; +use super::{MarketplaceHost, PLUGIN_NAME}; #[derive(Debug, Clone)] -pub(super) struct PluginInstallOptions { - pub(super) install_dir: PathBuf, - pub(super) force: bool, - pub(super) dry_run: bool, - pub(super) skip_doctor: bool, -} - -#[derive(Debug, Clone, Copy)] -pub(super) enum HostSelectionMode { - Install, - InstalledState, +pub(crate) struct PluginInstallOptions { + pub(crate) install_dir: PathBuf, + pub(crate) operation_lock_dir: PathBuf, + pub(crate) force: bool, + pub(crate) dry_run: bool, + pub(crate) skip_doctor: bool, } #[derive(Debug, Clone, Copy, Default)] @@ -42,46 +37,103 @@ impl HostRegistrationProgress { #[derive(Debug, Clone)] pub(super) struct PluginLayout { - pub(super) host: PluginHost, + pub(super) host_arg: &'static str, + pub(super) host_label: &'static str, pub(super) marketplace_root: PathBuf, pub(super) marketplace_manifest: PathBuf, pub(super) plugin_root: PathBuf, pub(super) plugin_manifest: PathBuf, + pub(super) mcp_config: PathBuf, + pub(super) generation_fence: PathBuf, + pub(super) generation_lock: PathBuf, pub(super) hooks_path: PathBuf, pub(super) state_path: PathBuf, } impl PluginLayout { - pub(super) fn new(host: PluginHost, install_dir: &Path) -> Self { - let marketplace_root = install_dir.join(format!("{}-marketplace", host_arg(host))); - let marketplace_manifest = match host { - PluginHost::Codex => marketplace_root - .join(".agents") - .join("plugins") - .join("marketplace.json"), - PluginHost::ClaudeCode => marketplace_root - .join(".claude-plugin") - .join("marketplace.json"), - PluginHost::All => unreachable!("all is expanded before layout resolution"), - }; + pub(super) fn new(host: impl MarketplaceHost, install_dir: &Path) -> Self { + let marketplace_root = install_dir.join(format!("{}-marketplace", host.install_arg())); + let marketplace_manifest = host + .marketplace_manifest_relative() + .iter() + .fold(marketplace_root.clone(), |path, component| { + path.join(component) + }); let plugin_root = marketplace_root.join("plugins").join(PLUGIN_NAME); - let plugin_manifest = match host { - PluginHost::Codex => plugin_root.join(".codex-plugin").join("plugin.json"), - PluginHost::ClaudeCode => plugin_root.join(".claude-plugin").join("plugin.json"), - PluginHost::All => unreachable!("all is expanded before layout resolution"), - }; + let plugin_manifest = host + .plugin_manifest_relative() + .iter() + .fold(plugin_root.clone(), |path, component| path.join(component)); + let mcp_config = plugin_root.join(".mcp.json"); + let generation_fence = plugin_root.join(GENERATION_FILE_NAME); + let generation_lock = install_dir.join(format!( + ".nemo-relay-{}-mcp-generation.lock", + host.install_arg() + )); let hooks_path = plugin_root.join("hooks").join("hooks.json"); let state_path = state_path(host, install_dir); Self { - host, + host_arg: host.install_arg(), + host_label: host.label(), marketplace_root, marketplace_manifest, plugin_root, plugin_manifest, + mcp_config, + generation_fence, + generation_lock, hooks_path, state_path, } } + + pub(super) fn validate_persisted_state(&self, state: &PluginState) -> Result<(), String> { + if !same_selected_path(&state.marketplace_root, &self.marketplace_root) + || !same_selected_path(&state.plugin_root, &self.plugin_root) + { + return Err(format!( + "refusing persisted {} plugin state outside the selected install layout {}", + self.host_label, + self.state_path.display() + )); + } + if self.marketplace_root.exists() { + let install_dir = self.state_path.parent().ok_or_else(|| { + format!( + "plugin state path {} has no parent", + self.state_path.display() + ) + })?; + let canonical_install = install_dir.canonicalize().map_err(|error| { + format!( + "failed to validate install directory {}: {error}", + install_dir.display() + ) + })?; + let canonical_marketplace = self.marketplace_root.canonicalize().map_err(|error| { + format!( + "failed to validate marketplace root {}: {error}", + self.marketplace_root.display() + ) + })?; + if !canonical_marketplace.starts_with(&canonical_install) { + return Err(format!( + "refusing persisted {} marketplace root outside the selected install directory", + self.host_label + )); + } + } + Ok(()) + } +} + +fn same_selected_path(persisted: &Path, selected: &Path) -> bool { + persisted == selected + || persisted + .canonicalize() + .ok() + .zip(selected.canonicalize().ok()) + .is_some_and(|(persisted, selected)| persisted == selected) } #[derive(Debug, Clone)] @@ -147,8 +199,8 @@ pub(super) fn write_state( layout: &PluginLayout, options: &PluginInstallOptions, ) -> Result<(), String> { - write_state_for_host( - layout.host, + write_state_for_host_arg( + layout.host_arg, &PluginState { marketplace_root: layout.marketplace_root.clone(), plugin_root: layout.plugin_root.clone(), @@ -165,7 +217,7 @@ pub(super) fn write_state( } pub(super) fn mark_plugin_setup_installed( - host: PluginHost, + host: impl MarketplaceHost, layout: &PluginLayout, options: &PluginInstallOptions, ) -> Result<(), String> { @@ -181,12 +233,21 @@ pub(super) fn mark_plugin_setup_installed( } pub(super) fn write_state_for_host( - host: PluginHost, + host: impl MarketplaceHost, state: &PluginState, install_dir: &Path, options: &PluginInstallOptions, ) -> Result<(), String> { - let path = state_path(host, install_dir); + write_state_for_host_arg(host.install_arg(), state, install_dir, options) +} + +fn write_state_for_host_arg( + host_arg: &str, + state: &PluginState, + install_dir: &Path, + options: &PluginInstallOptions, +) -> Result<(), String> { + let path = state_path_for_arg(host_arg, install_dir); if options.dry_run { println!("write {}", path.display()); return Ok(()); @@ -194,7 +255,7 @@ pub(super) fn write_state_for_host( write_json( &path, &json!({ - "host": host_arg(host), + "host": host_arg, "marketplaceRoot": state.marketplace_root, "pluginRoot": state.plugin_root, "hostUnregistered": state.host_plugin_removed && state.host_marketplace_removed, @@ -205,7 +266,7 @@ pub(super) fn write_state_for_host( ) } -pub(super) fn read_state(host: PluginHost, install_dir: &Path) -> Option { +pub(super) fn read_state(host: impl MarketplaceHost, install_dir: &Path) -> Option { let raw = fs::read_to_string(state_path(host, install_dir)).ok()?; let value = serde_json::from_str::(&raw).ok()?; let legacy_host_unregistered = value @@ -230,8 +291,12 @@ pub(super) fn read_state(host: PluginHost, install_dir: &Path) -> Option PathBuf { - install_dir.join(format!("{}.json", host_arg(host))) +pub(super) fn state_path(host: impl MarketplaceHost, install_dir: &Path) -> PathBuf { + state_path_for_arg(host.install_arg(), install_dir) +} + +fn state_path_for_arg(host_arg: &str, install_dir: &Path) -> PathBuf { + install_dir.join(format!("{host_arg}.json")) } pub(super) fn write_json(path: &Path, value: &Value) -> Result<(), String> { @@ -241,7 +306,7 @@ pub(super) fn write_json(path: &Path, value: &Value) -> Result<(), String> { } let mut bytes = serde_json::to_vec_pretty(value).map_err(|error| error.to_string())?; bytes.push(b'\n'); - fs::write(path, bytes).map_err(|error| format!("failed to write {}: {error}", path.display())) + crate::filesystem::atomic_write(path, &bytes) } pub(super) fn remove_path(path: &Path, options: &PluginInstallOptions) -> Result<(), String> { diff --git a/crates/cli/src/installation/mod.rs b/crates/cli/src/installation/mod.rs new file mode 100644 index 000000000..c156d917b --- /dev/null +++ b/crates/cli/src/installation/mod.rs @@ -0,0 +1,24 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Transactional installation primitives. + +use std::path::PathBuf; + +pub(crate) mod generation; +pub(crate) mod marketplace; +pub(crate) mod operation_lock; + +#[derive(Debug, Clone)] +pub(crate) struct InstallRequest { + pub(crate) install_dir: Option, + pub(crate) force: bool, + pub(crate) dry_run: bool, + pub(crate) skip_doctor: bool, +} + +#[derive(Debug, Clone)] +pub(crate) struct UninstallRequest { + pub(crate) install_dir: Option, + pub(crate) dry_run: bool, +} diff --git a/crates/cli/src/installation/operation_lock.rs b/crates/cli/src/installation/operation_lock.rs new file mode 100644 index 000000000..c60bcdfe8 --- /dev/null +++ b/crates/cli/src/installation/operation_lock.rs @@ -0,0 +1,113 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Cross-process serialization for per-user host state and one installation root. + +use std::fs::{self, File, OpenOptions}; +use std::path::{Path, PathBuf}; +use std::thread; +use std::time::{Duration, Instant}; + +use crate::filesystem::{LockAttempt, try_lock_exclusive}; + +pub(crate) const DEFAULT_OPERATION_LOCK_TIMEOUT: Duration = Duration::from_secs(5); +const LOCK_RETRY_INTERVAL: Duration = Duration::from_millis(25); + +pub(crate) struct PluginOperationLock { + _global_file: File, + _root_file: Option, +} + +impl PluginOperationLock { + pub(crate) fn acquire( + installation_key: &str, + global_lock_dir: &Path, + install_dir: &Path, + timeout: Duration, + ) -> Result { + let deadline = Instant::now() + timeout; + let global_file = acquire_lock_file(installation_key, global_lock_dir, deadline, "global")?; + ensure_lock_directory(install_dir)?; + let root_file = if directories_alias(global_lock_dir, install_dir) { + None + } else { + Some(acquire_lock_file( + installation_key, + install_dir, + deadline, + "install-root", + )?) + }; + Ok(Self { + _global_file: global_file, + _root_file: root_file, + }) + } +} + +fn ensure_lock_directory(directory: &Path) -> Result<(), String> { + fs::create_dir_all(directory).map_err(|error| { + format!( + "failed to create plugin operation lock directory {}: {error}", + directory.display() + ) + }) +} + +fn directories_alias(left: &Path, right: &Path) -> bool { + left == right + || matches!( + (fs::canonicalize(left), fs::canonicalize(right)), + (Ok(left), Ok(right)) if left == right + ) +} + +fn acquire_lock_file( + installation_key: &str, + directory: &Path, + deadline: Instant, + scope: &str, +) -> Result { + ensure_lock_directory(directory)?; + let path = operation_lock_path(installation_key, directory); + let mut options = OpenOptions::new(); + options.create(true).truncate(false).read(true).write(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let file = options.open(&path).map_err(|error| { + format!( + "failed to open plugin operation lock {}: {error}", + path.display() + ) + })?; + loop { + match try_lock_exclusive(&file) { + Ok(LockAttempt::Acquired) => return Ok(file), + Ok(LockAttempt::Contended) => { + if Instant::now() >= deadline { + return Err(format!( + "timed out waiting for another {} plugin install or uninstall operation on the {scope} lock at {}; wait for it to finish and retry", + installation_key, + directory.display() + )); + } + thread::sleep( + LOCK_RETRY_INTERVAL.min(deadline.saturating_duration_since(Instant::now())), + ); + } + Err(error) => { + return Err(format!( + "failed to lock plugin operation {}: {error}", + path.display() + )); + } + } + } +} + +pub(crate) fn operation_lock_path(installation_key: &str, install_dir: &Path) -> PathBuf { + install_dir.join(format!(".nemo-relay-{installation_key}-operation.lock")) +} diff --git a/crates/cli/src/installer.rs b/crates/cli/src/installer.rs deleted file mode 100644 index 23462d0d9..000000000 --- a/crates/cli/src/installer.rs +++ /dev/null @@ -1,417 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -use std::io::Read; -use std::time::Duration; - -use reqwest::header::{CONTENT_TYPE, HeaderMap, HeaderName, HeaderValue}; -use serde_json::{Value, json}; - -use crate::config::{CodingAgent, GatewayMode, HookForwardCommand}; -use crate::error::CliError; - -// Claude Code validates plugin hooks.json against a strict event-name whitelist — one unknown -// event rejects the entire plugin's hooks (no hooks register, silently). Both Claude vectors -// (the transparent-run temp plugin and the marketplace plugin) are plugin hooks.json, so every -// event here must exist in the oldest supported Claude Code. UserPromptExpansion sets that -// floor: 2.1.116 (verified empirically; 2.1.114 rejects it — see `claude_hook_floor_warning` -// in doctor.rs). Codex 0.129 has a smaller subset (SessionStart, UserPromptSubmit, PreToolUse, -// PostToolUse, Stop, PreCompact, PostCompact, PermissionRequest) and silently ignores events -// it doesn't recognize, so the union list is safe for both agents. -const HOOK_EVENTS: &[&str] = &[ - "SessionStart", - "UserPromptSubmit", - "UserPromptExpansion", - "PreToolUse", - "PostToolUse", - "PostToolUseFailure", - "PermissionRequest", - "SubagentStart", - "SubagentStop", - "Notification", - "Stop", - "PreCompact", - "PostCompact", - "SessionEnd", -]; - -const HOOK_FORWARD_TIMEOUT: Duration = Duration::from_secs(2); - -const HERMES_HOOK_EVENTS: &[&str] = &[ - "on_session_start", - "on_session_end", - "on_session_finalize", - "on_session_reset", - "pre_llm_call", - "post_llm_call", - "pre_api_request", - "post_api_request", - // Observer-only failure telemetry. Older Hermes versions ignore unknown hook names during - // install, while newer versions use this to close failed provider attempts. - "api_request_error", - "pre_tool_call", - "post_tool_call", - "subagent_start", - "subagent_stop", -]; - -/// Forwards a hook payload from an installed shell command to a running gateway. -/// -/// Empty stdin is normalized to `{}` so hooks that provide no payload still generate observable -/// marks. Delivery failures are fail-open by default to avoid blocking coding agents, but -/// `--fail-closed` converts missing URLs, HTTP failures, and upstream errors into process errors. -pub(crate) async fn hook_forward(command: HookForwardCommand) -> Result<(), CliError> { - validate_optional_json("session metadata", command.session_metadata.as_deref())?; - - let input = read_hook_payload()?; - let Some(url) = hook_forward_url(&command)? else { - return Ok(()); - }; - let response = send_hook_forward_request(&command, url, input).await?; - handle_hook_forward_response(response, command.fail_closed).await -} - -// Reads the native hook payload from stdin and normalizes empty payloads to JSON object syntax. -// This keeps hook commands observable even for agents or events that invoke hooks without input. -fn read_hook_payload() -> Result { - let mut input = String::new(); - std::io::stdin().read_to_string(&mut input)?; - if input.trim().is_empty() { - Ok("{}".to_string()) - } else { - Ok(input) - } -} - -// Builds the target gateway hook URL and applies fail-open/fail-closed behavior for missing -// gateway discovery. Returning `Ok(None)` is the fail-open path used by default hook commands. -fn hook_forward_url(command: &HookForwardCommand) -> Result, CliError> { - let Some(gateway_url) = resolve_hook_gateway_url( - command.agent, - command.gateway_url.clone(), - std::env::var("NEMO_RELAY_GATEWAY_URL").ok(), - ) else { - eprintln!( - "nemo-relay hook forward failed: missing gateway URL; pass --gateway-url or set NEMO_RELAY_GATEWAY_URL" - ); - if command.fail_closed { - return Err(CliError::Install( - "missing gateway URL; pass --gateway-url or set NEMO_RELAY_GATEWAY_URL".into(), - )); - } - return Ok(None); - }; - Ok(Some(format!( - "{}{}", - gateway_url.trim_end_matches('/'), - command.agent.hook_path() - ))) -} - -// Sends the hook payload with gateway-specific headers translated from CLI flags. The reqwest -// transport result is returned separately so response handling can preserve fail-open semantics. -async fn send_hook_forward_request( - command: &HookForwardCommand, - url: String, - input: String, -) -> Result, CliError> { - Ok(reqwest::Client::builder() - .timeout(HOOK_FORWARD_TIMEOUT) - .build()? - .post(url) - .headers(gateway_headers( - command.profile.as_deref(), - command.session_metadata.as_deref(), - command.gateway_mode, - )?) - .header(CONTENT_TYPE, "application/json") - .body(input) - .send() - .await) -} - -// Handles hook delivery results without changing agent control flow unless `--fail-closed` was -// requested. Successful non-empty endpoint bodies are printed verbatim for the invoking hook API. -async fn handle_hook_forward_response( - response: Result, - fail_closed: bool, -) -> Result<(), CliError> { - match response { - Ok(response) => { - let status = response.status(); - let body = response.text().await.unwrap_or_default(); - if !status.is_success() { - if let Some(reason) = guardrail_rejection_reason(&body) { - return Err(CliError::GuardrailRejected(reason)); - } - eprintln!("nemo-relay hook forward failed with HTTP {status}"); - if fail_closed { - return Err(CliError::Install(format!( - "hook forward failed with HTTP {status}" - ))); - } - return Ok(()); - } - if !body.is_empty() { - println!("{body}"); - } - Ok(()) - } - Err(error) => { - eprintln!("nemo-relay hook forward failed: {error}"); - if fail_closed { - Err(CliError::Upstream(error)) - } else { - Ok(()) - } - } - } -} - -fn guardrail_rejection_reason(body: &str) -> Option { - let value: Value = serde_json::from_str(body).ok()?; - let error = value.get("error")?; - (error.get("type").and_then(Value::as_str) == Some("nemo_relay_guardrail_rejected")) - .then(|| { - error - .get("reason") - .and_then(Value::as_str) - .or_else(|| error.get("message").and_then(Value::as_str)) - .map(ToOwned::to_owned) - }) - .flatten() -} - -// Chooses the gateway URL for hook-forward. Hermes prefers the runtime environment URL because -// its hooks are installed persistently by setup but reused under `nemo-relay hermes` with an -// ephemeral gateway; other agents prefer the installed command URL for stable configuration. -fn resolve_hook_gateway_url( - agent: CodingAgent, - command_url: Option, - env_url: Option, -) -> Option { - match agent { - CodingAgent::Hermes => env_url.or(command_url), - _ => command_url.or(env_url), - } -} - -/// Generates native hook configuration for the selected agent. -/// -/// The returned value always has a top-level `hooks` object. Claude/Codex use command hook -/// groups with optional tool matchers, while Hermes uses direct command entries. -pub(crate) fn generated_hooks(agent: CodingAgent, command: &str) -> Value { - match agent { - CodingAgent::ClaudeCode => claude_hooks(command), - CodingAgent::Codex => codex_hooks(command), - CodingAgent::Hermes => hermes_hooks(command), - } -} - -// Returns the shell command a hook should run to forward an event to the gateway. Callers must -// pass the executable they want hooks to invoke. Transparent-run callers should pass the absolute -// path of the currently running gateway binary so spawned hook subprocesses do not depend on the -// user's `PATH` (which Codex/Claude inherit but which typically does not include -// `target/debug` or other dev locations); persistent-install callers can pass the bare name -// `"nemo-relay"` because the user is expected to have the binary on `PATH` after install. -pub(crate) fn hook_forward_command(executable: &str, agent: CodingAgent) -> String { - format!("{executable} hook-forward {}", agent.as_arg()) -} - -fn claude_hooks(command: &str) -> Value { - hooks_for_events(HOOK_EVENTS, command, true) -} - -fn codex_hooks(command: &str) -> Value { - hooks_for_events(HOOK_EVENTS, command, true) -} - -// Generates Hermes YAML-compatible hook groups. Hermes expects direct command entries rather than -// the nested `type = command` group format used by Claude and Codex. -pub(crate) fn hermes_hooks(command: &str) -> Value { - let hooks: serde_json::Map = HERMES_HOOK_EVENTS - .iter() - .map(|event| { - ( - (*event).to_string(), - json!([{ - "command": command, - "timeout": 30 - }]), - ) - }) - .collect(); - json!({ "hooks": Value::Object(hooks) }) -} - -// Generates hook groups for Claude/Codex events and adds a wildcard matcher to tool events when -// the target agent requires matcher-scoped tool hooks. Non-tool events omit matchers so they fire -// for the full lifecycle. -fn hooks_for_events(events: &[&str], command: &str, matcher_for_tools: bool) -> Value { - let hooks: serde_json::Map = events - .iter() - .map(|event| { - let mut group = serde_json::Map::new(); - if matcher_for_tools && event_matches_tools(event) { - group.insert("matcher".into(), json!("*")); - } - group.insert( - "hooks".into(), - json!([{ - "type": "command", - "command": command, - "timeout": 30 - }]), - ); - ( - (*event).to_string(), - Value::Array(vec![Value::Object(group)]), - ) - }) - .collect(); - json!({ "hooks": Value::Object(hooks) }) -} - -// Identifies hook events that should receive wildcard tool matchers. The list includes current -// Claude/Codex spellings. -fn event_matches_tools(event: &str) -> bool { - matches!( - event, - "PreToolUse" | "PostToolUse" | "PostToolUseFailure" | "PermissionRequest" - ) -} - -/// Merges generated hook groups into an existing hook configuration without duplicating groups. -/// -/// Missing files are represented by `Null` and become empty objects. Existing non-object roots, -/// non-object `hooks`, non-array event hooks, or malformed generated hooks fail closed because -/// writing through those shapes would corrupt user configuration. -pub(crate) fn merge_hooks(existing: Value, generated: Value) -> Result { - let mut root = hook_config_root(existing)?; - let hooks = hooks_object_mut(&mut root)?; - let generated_hooks = generated_hooks_object(&generated)?; - for (event, groups) in generated_hooks { - merge_event_hook_groups(hooks, event, groups)?; - } - Ok(root) -} - -// Normalizes an existing hook config root. Missing files arrive as `Null`, valid JSON/YAML config -// roots remain objects, and other shapes are rejected before any write can occur. -fn hook_config_root(existing: Value) -> Result { - match existing { - Value::Null => Ok(json!({})), - Value::Object(object) => Ok(Value::Object(object)), - _ => Err(CliError::Install( - "hook config must be a JSON object".into(), - )), - } -} - -// Returns the mutable `hooks` object from a config root, creating it when absent. A non-object -// `hooks` field is considered user config corruption and is not overwritten. -fn hooks_object_mut(root: &mut Value) -> Result<&mut serde_json::Map, CliError> { - root.as_object_mut() - .expect("root checked as object") - .entry("hooks") - .or_insert_with(|| json!({})) - .as_object_mut() - .ok_or_else(|| CliError::Install("hooks must be a JSON object".into())) -} - -// Validates generated hook shape before merging. Generated hooks are internal data, but checking -// here keeps test failures localized if an agent bundle generator regresses. -fn generated_hooks_object(generated: &Value) -> Result<&serde_json::Map, CliError> { - generated - .get("hooks") - .and_then(Value::as_object) - .ok_or_else(|| CliError::Install("generated hooks were malformed".into())) -} - -// Appends missing generated groups for one hook event. Equality comparison is exact so repeated -// writes are idempotent without trying to interpret vendor-specific hook group schemas. -fn merge_event_hook_groups( - hooks: &mut serde_json::Map, - event: &str, - groups: &Value, -) -> Result<(), CliError> { - let groups = groups - .as_array() - .ok_or_else(|| CliError::Install("generated hook groups were malformed".into()))?; - let event_groups = hooks.entry(event.to_string()).or_insert_with(|| json!([])); - let event_groups = event_groups - .as_array_mut() - .ok_or_else(|| CliError::Install(format!("{event} hooks must be an array")))?; - for group in groups { - if !event_groups.iter().any(|existing| existing == group) { - event_groups.push(group.clone()); - } - } - Ok(()) -} - -/// Parses Hermes YAML, merges generated hooks through the shared JSON hook merger, and serializes -/// back to YAML. Empty input is treated as no existing configuration. -pub(crate) fn merge_hermes_config(existing: &str, generated: Value) -> Result { - let existing = if existing.trim().is_empty() { - Value::Null - } else { - serde_yaml::from_str(existing) - .map_err(|error| CliError::Install(format!("invalid YAML in Hermes config: {error}")))? - }; - let merged = merge_hooks(existing, generated)?; - serde_yaml::to_string(&merged).map_err(|error| CliError::Install(error.to_string())) -} - -// Validates optional JSON strings before they are embedded into hook-forward headers. Catches -// quoting/config mistakes at hook-fire time rather than after the request reaches the gateway. -fn validate_optional_json(name: &str, value: Option<&str>) -> Result<(), CliError> { - if let Some(value) = value { - serde_json::from_str::(value) - .map_err(|error| CliError::Install(format!("invalid {name}: {error}")))?; - } - Ok(()) -} - -// Converts optional session/export/gateway settings into gateway headers for hook-forward. Each -// absent value is omitted so the server can fall back to file, environment, or default config. -fn gateway_headers( - profile: Option<&str>, - session_metadata: Option<&str>, - gateway_mode: Option, -) -> Result { - let mut headers = HeaderMap::new(); - insert_header(&mut headers, "x-nemo-relay-config-profile", profile)?; - insert_header( - &mut headers, - "x-nemo-relay-session-metadata", - session_metadata, - )?; - insert_header( - &mut headers, - "x-nemo-relay-gateway-mode", - gateway_mode.map(GatewayMode::as_arg), - )?; - Ok(headers) -} - -// Inserts one optional header after validating it is legal HTTP header text. Invalid values are -// reported as installer errors because they came from generated or user-provided hook options. -fn insert_header( - headers: &mut HeaderMap, - name: &'static str, - value: Option<&str>, -) -> Result<(), CliError> { - if let Some(value) = value { - headers.insert( - HeaderName::from_static(name), - HeaderValue::from_str(value) - .map_err(|error| CliError::Install(format!("invalid header {name}: {error}")))?, - ); - } - Ok(()) -} - -#[cfg(test)] -#[path = "../tests/coverage/installer_tests.rs"] -mod tests; diff --git a/crates/cli/src/launcher.rs b/crates/cli/src/launcher.rs deleted file mode 100644 index 7c3fa6116..000000000 --- a/crates/cli/src/launcher.rs +++ /dev/null @@ -1,1065 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -use std::path::{Path, PathBuf}; -use std::process::ExitCode; -use std::time::{Duration, SystemTime, UNIX_EPOCH}; - -use nemo_relay::observability::plugin_component::{ - AtifStorageConfig, OBSERVABILITY_PLUGIN_KIND, ObservabilityConfig, -}; -use nemo_relay::plugin::PluginConfig; -use reqwest::Client; -use serde_json::{Value, json}; -use tokio::net::TcpListener; -use tokio::process::Command; -use tokio::sync::oneshot; -use tokio::task::JoinHandle; - -use crate::config::{ - AgentConfigs, CodingAgent, EasyPathCommand, GatewayConfig, ResolvedConfig, RunCommand, - ServerArgs, any_config_file_exists, resolve_run_config, -}; -use crate::error::CliError; -use crate::installer::{generated_hooks, hook_forward_command, merge_hermes_config}; -use crate::plugins::lifecycle::ActiveDynamicPluginComponent; -use crate::server; - -/// Runs a child coding-agent command behind an ephemeral local gateway. -/// -/// The gateway binds to an OS-assigned loopback port, prepares agent-specific hook/gateway wiring, -/// waits for health before spawning the child, and restores temporary files after the child and -/// server shut down. The child's exit status is preserved when it fits in `ExitCode`; otherwise the -/// launcher reports generic failure. -pub(crate) async fn run( - command: RunCommand, - inherited: Option<&ServerArgs>, -) -> Result { - let run = TransparentRun::new(command, inherited).await?; - run.print_if_requested(); - run.execute().await -} - -/// Runs the easy-path bare-agent shortcut (`nemo-relay claude`, `nemo-relay codex`, etc.). -/// -/// If no config file is present at any discovery layer, this fires the interactive setup inline -/// (`crate::setup::run`) which writes a `config.toml`, then proceeds to launch the agent. When -/// config IS present, the easy path constructs a synthetic `RunCommand` and delegates to the -/// same transparent-run pipeline `nemo-relay run` uses — same observability wiring, same agent -/// argv resolution, same lifecycle management. -pub(crate) async fn easy_path( - agent: CodingAgent, - command: EasyPathCommand, - inherited: Option<&ServerArgs>, -) -> Result { - // Explicit `--config ` short-circuits the discovery-based setup trigger: when the - // user has pointed at a specific file, that file is the contract — fire setup only if it - // doesn't exist yet, and never run setup just because no config lives at any default - // discovery location. - let explicit_config = inherited.and_then(|args| args.config.as_deref()); - let needs_setup = match explicit_config { - Some(path) => !path.exists(), - None => !any_config_file_exists(), - }; - if needs_setup { - // No config anywhere — fire setup inline, scoped to the agent the user typed. After - // it returns, config discovery will pick up the freshly-written `config.toml` and - // `run()` below will see a populated environment. If setup errors (non-TTY, user - // cancelled), surface that directly. - crate::setup::run(Some(agent)).await?; - } - let synthetic = RunCommand { - agent: Some(agent), - // Forward the explicit config path so `run` parses the same file the user asked for, - // rather than re-discovering from defaults. - config: explicit_config.map(std::path::Path::to_path_buf), - openai_base_url: None, - anthropic_base_url: None, - session_metadata: None, - plugin_config_path: None, - dry_run: false, - print: false, - command: command.command, - }; - run(synthetic, inherited).await -} - -struct TransparentRun { - agent: CodingAgent, - prepared: PreparedRun, - resolved: ResolvedConfig, - dynamic_plugins: Vec, - listener: TcpListener, - gateway_url: String, - dry_run: bool, - print: bool, -} - -impl TransparentRun { - // Resolves configuration, binds the ephemeral listener, and builds agent-specific launch wiring - // without starting the gateway or spawning the child command. - async fn new(command: RunCommand, inherited: Option<&ServerArgs>) -> Result { - let dry_run = command.dry_run; - let print = command.print; - let explicit_config = command - .config - .as_ref() - .or_else(|| inherited.and_then(|args| args.config.as_ref())); - let mut resolved = resolve_run_config(&command, inherited)?; - let dynamic_plugins = if dry_run { - Vec::new() - } else { - crate::plugins::lifecycle::active_dynamic_plugin_components(explicit_config, &resolved)? - }; - let (agent, argv) = resolve_agent_and_argv(&command, &resolved.agents)?; - let listener = TcpListener::bind("127.0.0.1:0").await?; - let address = listener.local_addr()?; - let gateway_url = format!("http://{address}"); - resolved.gateway.bind = address; - - let prepared = PreparedRun::new(agent, argv, &gateway_url, &resolved, dry_run)?; - Ok(Self { - agent, - prepared, - resolved, - dynamic_plugins, - listener, - gateway_url, - dry_run, - print, - }) - } - - // Emits the resolved run plan when requested. Dry runs always print because inspection is their - // primary behavior; live runs print only when `--print` was passed. - fn print_if_requested(&self) { - if self.print || self.dry_run { - self.prepared - .print(self.agent, &self.gateway_url, &self.resolved); - } - } - - // Runs the prepared child command unless this is an inspection-only dry run. - async fn execute(self) -> Result { - if self.dry_run { - return Ok(ExitCode::SUCCESS); - } - self.prepared - .print_live_status(self.agent, &self.gateway_url, &self.resolved); - execute_live_run_with_dynamic( - self.listener, - self.resolved.gateway, - self.dynamic_plugins, - &self.gateway_url, - self.prepared, - ) - .await - } -} - -// Starts the gateway, waits for readiness, runs the child command, restores temporary state, and then -// maps the child process status to the launcher's exit code. -#[cfg(test)] -async fn execute_live_run( - listener: TcpListener, - gateway_config: GatewayConfig, - gateway_url: &str, - prepared: PreparedRun, -) -> Result { - execute_live_run_with_dynamic(listener, gateway_config, Vec::new(), gateway_url, prepared).await -} - -async fn execute_live_run_with_dynamic( - listener: TcpListener, - gateway_config: GatewayConfig, - dynamic_plugins: Vec, - gateway_url: &str, - prepared: PreparedRun, -) -> Result { - let running_server = RunningGateway::start(listener, gateway_config, dynamic_plugins); - if let Err(error) = wait_for_health(gateway_url).await { - let restore = prepared.restore(); - let server_result = running_server.stop().await; - restore?; - server_result?; - return Err(error); - } - let status = prepared.spawn_and_wait().await; - let restore = prepared.restore(); - let server_result = running_server.stop().await; - restore?; - server_result?; - - Ok(exit_code(status?)) -} - -// Resolves the launched agent and argv from either an explicit command or a configured per-agent -// command. Agent inference only happens from argv[0] when `--agent` was omitted, so explicit agent -// selection can wrap commands whose executable name is not recognizable. -fn resolve_agent_and_argv( - command: &RunCommand, - agents: &AgentConfigs, -) -> Result<(CodingAgent, Vec), CliError> { - let argv = resolved_argv(command, agents)?; - let agent = resolved_agent(command, &argv)?; - Ok((agent, argv)) -} - -// Resolves the full argv to spawn. When `--agent` is set (the easy-path and explicit `--agent` -// flows both go through this case), the configured agent command is the base argv and anything -// after `--` is appended as pass-through args. When `--agent` is absent, `command.command` IS -// the full argv (e.g., `nemo-relay run -- codex --model X` runs that exact command and infers -// the agent from argv[0]). -fn resolved_argv(command: &RunCommand, agents: &AgentConfigs) -> Result, CliError> { - if let Some(agent) = command.agent { - let mut argv = configured_command(agent, agents) - .unwrap_or_else(|| vec![default_command_for(agent).to_string()]); - argv.extend(command.command.iter().cloned()); - return Ok(argv); - } - if command.command.is_empty() { - return Err(CliError::Launch( - "missing command; pass -- or --agent with a configured command".into(), - )); - } - Ok(command.command.clone()) -} - -// Default agent binary names used when no `[agents.] command = "..."` override is in the -// resolved config. Matches the executable on $PATH that the wizard's detection probes for. -const fn default_command_for(agent: CodingAgent) -> &'static str { - match agent { - CodingAgent::ClaudeCode => "claude", - CodingAgent::Codex => "codex", - CodingAgent::Hermes => "hermes", - } -} - -// Uses an explicit `--agent` when present and otherwise infers the agent from argv[0]. Inference is -// intentionally late so configured commands and direct CLI commands share the same validation path. -fn resolved_agent(command: &RunCommand, argv: &[String]) -> Result { - if let Some(agent) = command.agent { - return Ok(agent); - } - CodingAgent::infer(&argv[0]).ok_or_else(|| { - CliError::Launch(format!( - "could not infer coding agent from command {:?}; pass --agent claude, --agent codex, or --agent hermes", - argv[0] - )) - }) -} - -// Splits a configured command string into argv words for run mode. This intentionally uses simple -// whitespace splitting because config command values are a convenience fallback; complex shell -// commands should be passed after `--` by the caller. -fn configured_command(agent: CodingAgent, agents: &AgentConfigs) -> Option> { - let command = match agent { - CodingAgent::ClaudeCode => agents.claude.command.as_ref(), - CodingAgent::Codex => agents.codex.command.as_ref(), - CodingAgent::Hermes => agents.hermes.command.as_ref(), - }?; - let argv: Vec<_> = command.split_whitespace().map(ToOwned::to_owned).collect(); - (!argv.is_empty()).then_some(argv) -} - -struct PreparedRun { - argv: Vec, - env: Vec<(String, String)>, - temp_dirs: Vec, - hermes_restore: Option, - notes: Vec, -} - -struct HermesRestore { - path: PathBuf, - backup_path: Option, - had_original: bool, -} - -struct RunningGateway { - shutdown_tx: oneshot::Sender<()>, - task: JoinHandle>, -} - -impl RunningGateway { - // Starts the gateway listener on a background task and keeps the shutdown sender paired with the - // task handle so health failures and normal exits use identical cleanup semantics. - fn start( - listener: TcpListener, - config: crate::config::GatewayConfig, - dynamic_plugins: Vec, - ) -> Self { - let (shutdown_tx, shutdown_rx) = oneshot::channel(); - let task = tokio::spawn(async move { - server::serve_listener_with_dynamic( - listener, - config, - dynamic_plugins, - Some(shutdown_rx), - ) - .await - }); - Self { shutdown_tx, task } - } - - // Requests shutdown and joins the server task. The send can fail only if the task already exited; - // the join result still captures whether serving ended cleanly. - async fn stop(self) -> Result<(), CliError> { - let _ = self.shutdown_tx.send(()); - self.task - .await - .map_err(|error| CliError::Launch(format!("gateway task failed: {error}")))? - } -} - -impl PreparedRun { - // Builds the launch plan and applies only the preparation needed by the selected agent. - // Dry-run preparation records equivalent notes and argv/env changes without writing temporary - // hook files or patching user/project configuration. - fn new( - agent: CodingAgent, - argv: Vec, - gateway_url: &str, - resolved: &ResolvedConfig, - dry_run: bool, - ) -> Result { - let mut run = Self { - argv, - env: vec![("NEMO_RELAY_GATEWAY_URL".into(), gateway_url.into())], - temp_dirs: Vec::new(), - hermes_restore: None, - notes: Vec::new(), - }; - if let Some(path) = path_with_transparent_hook_dir() { - run.env.push(("PATH".into(), path)); - } - match agent { - CodingAgent::ClaudeCode => { - if dry_run { - run.prepare_claude_dry(gateway_url); - } else { - run.prepare_claude(gateway_url)?; - } - } - CodingAgent::Codex => run.prepare_codex(gateway_url), - CodingAgent::Hermes => { - if dry_run { - run.prepare_hermes_dry( - gateway_url, - resolved.agents.hermes.hooks_path.as_deref(), - )?; - } else { - run.prepare_hermes(gateway_url, resolved.agents.hermes.hooks_path.as_deref())?; - } - } - } - Ok(run) - } - - // Records the Claude Code argv/env changes that would be made during a real run. The temporary - // plugin path is symbolic so printed dry-run output is deterministic and non-mutating. - fn prepare_claude_dry(&mut self, gateway_url: &str) { - insert_after_agent( - &mut self.argv, - CodingAgent::ClaudeCode, - [ - "--plugin-dir".into(), - "".into(), - ], - ); - self.env - .push(("ANTHROPIC_BASE_URL".into(), gateway_url.to_string())); - self.notes - .push("would generate a temporary Claude Code plugin directory".into()); - } - - // Creates a temporary Claude Code plugin containing gateway hooks and points Claude at both - // that plugin directory and the gateway Anthropic-compatible gateway URL. - fn prepare_claude(&mut self, gateway_url: &str) -> Result<(), CliError> { - let root = temp_dir("nemo-relay-claude-plugin")?; - std::fs::create_dir_all(root.join(".claude-plugin"))?; - std::fs::create_dir_all(root.join("hooks"))?; - std::fs::write( - root.join(".claude-plugin/plugin.json"), - serde_json::to_vec_pretty(&json!({ - "name": "nemo-relay-cli", - "version": env!("CARGO_PKG_VERSION"), - "description": "Temporary NeMo Relay gateway hooks" - })) - .map_err(|error| CliError::Launch(error.to_string()))?, - )?; - write_hooks( - &root.join("hooks/hooks.json"), - generated_hooks( - CodingAgent::ClaudeCode, - &hook_forward_command(&transparent_hook_executable(), CodingAgent::ClaudeCode), - ), - )?; - insert_after_agent( - &mut self.argv, - CodingAgent::ClaudeCode, - ["--plugin-dir".into(), root.display().to_string()], - ); - self.env - .push(("ANTHROPIC_BASE_URL".into(), gateway_url.to_string())); - self.temp_dirs.push(root); - Ok(()) - } - - // Injects Codex hook and provider configuration through repeated `--config` flags. Codex - // reserves built-in provider IDs, so run mode installs a temporary provider alias instead of - // overriding `model_providers.openai`. Uses `features.hooks=true` introduced in codex-cli - // 0.129. Requires codex-cli >= 0.129.0. - fn prepare_codex(&mut self, gateway_url: &str) { - // Codex resolves auth via `CodexAuth::from_auth_dot_json` (`codex-rs/login/src/auth/ - // manager.rs`): `auth_mode=ApiKey` uses `OPENAI_API_KEY`, `auth_mode=Chatgpt` uses the - // OAuth token from `~/.codex/auth.json`. With `requires_openai_auth=true` the provider - // config tells Codex to attach whichever credential it has. The gateway then either - // substitutes `OPENAI_API_KEY` (routing to `api.openai.com`) or forwards the JWT as-is - // (routing to `chatgpt.com/backend-api/codex`). Warn when neither source is present. - let has_openai_key = std::env::var("OPENAI_API_KEY") - .ok() - .is_some_and(|v| !v.is_empty()); - // Codex persists OAuth tokens to `~/.codex/auth.json` via `AuthDotJson` in - // `codex-rs/login/src/auth/storage.rs`. Check for the file rather than parsing it — - // Codex handles token refresh itself at runtime. - let has_codex_auth = std::env::var_os("HOME") - .or_else(|| std::env::var_os("USERPROFILE")) - .map(|h| { - std::path::PathBuf::from(h) - .join(".codex/auth.json") - .exists() - }) - .unwrap_or(false); - if !has_openai_key && !has_codex_auth { - eprintln!( - "warning: No OpenAI credentials found. Either export OPENAI_API_KEY \ - (e.g. `export OPENAI_API_KEY=sk-...`), log in to codex (`codex --login`), \ - or pass `--openai-base-url` to an upstream that needs no key." - ); - } - let hook_command = hook_forward_command(&transparent_hook_executable(), CodingAgent::Codex); - let mut args = vec![ - "--config".to_string(), - "features.hooks=true".to_string(), - "--config".to_string(), - "model_provider=\"nemo-relay-openai\"".to_string(), - "--config".to_string(), - codex_gateway_provider_config(gateway_url), - ]; - for (event, groups) in generated_hooks(CodingAgent::Codex, &hook_command)["hooks"] - .as_object() - .into_iter() - .flatten() - { - args.push("--config".to_string()); - args.push(format!("hooks.{event}={}", hook_groups_toml(groups))); - } - insert_after_agent(&mut self.argv, CodingAgent::Codex, args); - } - - // Hermes discovers hooks from `.hermes/config.yaml` instead of command-line flags. For - // transparent runs, temporarily merge gateway hook-forward entries into the configured Hermes - // hook file, then restore it after the child exits. - fn prepare_hermes( - &mut self, - gateway_url: &str, - hooks_path: Option<&std::path::Path>, - ) -> Result<(), CliError> { - let path = hermes_hooks_path(hooks_path)?; - let (had_original, backup_path) = backup_existing_hermes_hooks(&path)?; - write_merged_hermes_config(&path, gateway_url)?; - self.env.push(( - "OPENAI_BASE_URL".into(), - hermes_openai_base_url(gateway_url), - )); - self.env.push(("HERMES_ACCEPT_HOOKS".into(), "1".into())); - self.notes.push(format!( - "temporarily merged NeMo Relay hooks into {}", - path.display() - )); - self.hermes_restore = Some(HermesRestore { - path, - backup_path, - had_original, - }); - Ok(()) - } - - // Records the Hermes hook file that would be patched during a real run without touching the - // filesystem, preserving dry-run as an inspection-only operation. - fn prepare_hermes_dry( - &mut self, - gateway_url: &str, - hooks_path: Option<&std::path::Path>, - ) -> Result<(), CliError> { - let path = hermes_hooks_path(hooks_path)?; - self.env.push(( - "OPENAI_BASE_URL".into(), - hermes_openai_base_url(gateway_url), - )); - self.env.push(("HERMES_ACCEPT_HOOKS".into(), "1".into())); - self.notes.push(format!( - "would temporarily merge NeMo Relay hooks into {}", - path.display() - )); - Ok(()) - } - - // Spawns the prepared child process with injected environment and waits for its exit status. - // Stdio is inherited by default so agent interaction remains unchanged in transparent mode. - async fn spawn_and_wait(&self) -> Result { - let mut command = Command::new(&self.argv[0]); - command.args(&self.argv[1..]); - for (name, value) in &self.env { - command.env(name, value); - } - let mut child = command.spawn()?; - child.wait().await.map_err(CliError::from) - } - - // Removes temporary directories and restores patched hook files after the child exits. Restore - // errors are surfaced after the child status is collected so cleanup problems are not hidden. - fn restore(&self) -> Result<(), CliError> { - for dir in &self.temp_dirs { - let _ = std::fs::remove_dir_all(dir); - } - - if let Some(hermes) = &self.hermes_restore { - restore_hook_file( - &hermes.path, - hermes.backup_path.as_deref(), - hermes.had_original, - "Hermes", - )?; - } - Ok(()) - } - - // Prints a compact pre-launch status banner so users see at a glance which plugin - // configuration is active, including plugin names and enabled/disabled state, before the - // agent's own UI takes over the terminal. Always emitted on stderr so it never contaminates - // piped/redirected agent output, and suppressed entirely when stdout is not a TTY — scripts - // capturing the agent stream get a clean pipe, interactive users still get the bordered frame. - // Distinct from `print()`, which is the verbose `--print` / `--dry-run` dump intended for - // inspection. - fn print_live_status(&self, agent: CodingAgent, gateway_url: &str, resolved: &ResolvedConfig) { - // Suppress entirely on non-TTY stdout: when the user redirects the agent's stream to a - // file or pipes it into another tool, no banner should appear ahead of that output. - if !std::io::IsTerminal::is_terminal(&std::io::stdout()) { - return; - } - - let mut lines: Vec = Vec::new(); - lines.push(format!("NeMo Relay → {}", agent.as_arg())); - lines.push(format!(" Gateway {gateway_url}")); - let destinations = exporter_destinations(&resolved.gateway); - if destinations.is_empty() { - lines.push(" Exporters not configured".into()); - } else { - for (index, destination) in destinations.iter().enumerate() { - lines.push(format!( - " {}{}", - if index == 0 { - "Exporters " - } else { - " " - }, - destination - )); - } - } - if !self.notes.is_empty() { - lines.push(String::new()); - for note in &self.notes { - lines.push(format!("⚠ {note}")); - } - } - - // Color decisions key off stderr (where we actually emit), not stdout. - let use_color = std::io::IsTerminal::is_terminal(&std::io::stderr()) - && std::env::var_os("NO_COLOR").is_none(); - eprint!("{}", render_status_frame(&lines, use_color)); - } - - // Prints the resolved transparent-run plan, including dynamic gateway URL, upstream base URLs, - // argv/env injection, and any agent-specific notes or temporary files. - fn print(&self, agent: CodingAgent, gateway_url: &str, resolved: &ResolvedConfig) { - println!("agent = {}", agent.as_arg()); - println!("gateway_url = {gateway_url}"); - println!("openai_base_url = {}", resolved.gateway.openai_base_url); - println!( - "anthropic_base_url = {}", - resolved.gateway.anthropic_base_url - ); - println!( - "max_hook_payload_bytes = {}", - resolved.gateway.max_hook_payload_bytes - ); - println!( - "max_passthrough_body_bytes = {}", - resolved.gateway.max_passthrough_body_bytes - ); - let destinations = exporter_destinations(&resolved.gateway); - if destinations.is_empty() { - println!("exporters = not_configured"); - } else { - for destination in destinations { - println!("exporter = {destination}"); - } - } - println!("argv = {}", self.argv.join(" ")); - for (name, value) in &self.env { - println!("env.{name} = {value}"); - } - for note in &self.notes { - println!("note = {note}"); - } - } -} - -/// Renders a bordered status frame for daemon and transparent-run startup output. -pub(crate) fn render_status_frame(lines: &[String], color: bool) -> String { - let max_w = lines.iter().map(|l| l.chars().count()).max().unwrap_or(0); - // 1-char padding on each side of the longest line. - let inner = max_w + 2; - let mut output = String::new(); - - output.push('\n'); - push_status_border(&mut output, '╭', '╮', inner, color); - for line in lines { - let pad = max_w - line.chars().count(); - let body = format!(" {line}{spaces} ", spaces = " ".repeat(pad)); - if color { - output.push_str(&format!( - "\x1b[38;5;112m│\x1b[0m{body}\x1b[38;5;112m│\x1b[0m\n" - )); - } else { - output.push_str(&format!("│{body}│\n")); - } - } - push_status_border(&mut output, '╰', '╯', inner, color); - output.push('\n'); - output -} - -pub(crate) fn exporter_destinations(config: &GatewayConfig) -> Vec { - let Some(plugin_config) = config.plugin_config.as_ref() else { - return Vec::new(); - }; - let Ok(plugin_config) = serde_json::from_value::(plugin_config.clone()) else { - return vec!["configured (invalid plugin config)".into()]; - }; - let Some(component) = plugin_config - .components - .iter() - .find(|component| component.kind == OBSERVABILITY_PLUGIN_KIND) - else { - return Vec::new(); - }; - if !component.enabled { - return Vec::new(); - } - let Ok(observability) = - serde_json::from_value::(Value::Object(component.config.clone())) - else { - return vec!["Observability configured (invalid config)".into()]; - }; - observability_exporter_destinations(&observability) -} - -fn observability_exporter_destinations(config: &ObservabilityConfig) -> Vec { - let mut destinations = Vec::new(); - if let Some(section) = config.atof.as_ref().filter(|section| section.enabled) { - for sink in §ion.sinks { - match sink { - nemo_relay::observability::plugin_component::AtofSinkSectionConfig::File(file) => { - let directory = file - .output_directory - .clone() - .unwrap_or_else(current_output_directory); - let path = directory.join( - file.filename - .clone() - .unwrap_or_else(|| "nemo-relay-events-.jsonl".into()), - ); - destinations.push(format!("ATOF {}", path.display())); - } - nemo_relay::observability::plugin_component::AtofSinkSectionConfig::Stream( - stream, - ) => { - destinations.push(format!("ATOF {}", stream.url)); - } - } - } - } - if let Some(section) = config.atif.as_ref().filter(|section| section.enabled) { - if section.storage.is_empty() { - let directory = section - .output_directory - .clone() - .unwrap_or_else(current_output_directory); - destinations.push(format!( - "ATIF {}", - directory.join(§ion.filename_template).display() - )); - } else { - // Non-empty `storage` skips the local file write and uploads to each remote backend - // instead, so report the actual upload destinations rather than a local path that is - // never written. - for backend in §ion.storage { - destinations.push(format!("ATIF {}", atif_storage_destination(backend))); - } - } - } - if let Some(section) = config - .opentelemetry - .as_ref() - .filter(|section| section.enabled) - { - destinations.push(format!( - "OpenTelemetry {}", - section - .endpoint - .as_deref() - .unwrap_or("OTLP endpoint from environment/default") - )); - } - if let Some(section) = config - .openinference - .as_ref() - .filter(|section| section.enabled) - { - destinations.push(format!( - "OpenInference {}", - section - .endpoint - .as_deref() - .unwrap_or("OTLP endpoint from environment/default") - )); - } - destinations -} - -// Renders a single ATIF remote storage backend as a human-readable destination for the status -// banner. S3 keys are summarized as `s3:///`; the per-trajectory object suffix -// is omitted because it is only known once a session starts. -fn atif_storage_destination(storage: &AtifStorageConfig) -> String { - match storage { - AtifStorageConfig::Http(http) => http.endpoint.clone(), - AtifStorageConfig::S3(s3) => { - let prefix = s3.key_prefix.as_deref().unwrap_or("").trim_matches('/'); - if prefix.is_empty() { - format!("s3://{}", s3.bucket) - } else { - format!("s3://{}/{}", s3.bucket, prefix) - } - } - } -} - -fn current_output_directory() -> PathBuf { - std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")) -} - -// Converts a process status into the launcher status code while preserving normal 0-255 exits. Signal -// exits and platform-specific out-of-range codes become generic failure. -fn exit_code(status: std::process::ExitStatus) -> ExitCode { - status - .code() - .and_then(|code| u8::try_from(code).ok()) - .map(ExitCode::from) - .unwrap_or(ExitCode::FAILURE) -} - -// Polls the ephemeral gateway health endpoint for roughly one second before launching the agent. -// Startup failures return a launcher error so the child command is never run against a dead proxy. -async fn wait_for_health(gateway_url: &str) -> Result<(), CliError> { - let client = Client::new(); - let url = format!("{}/healthz", gateway_url.trim_end_matches('/')); - for _ in 0..50 { - if let Ok(response) = client.get(&url).send().await - && response.status().is_success() - { - return Ok(()); - } - tokio::time::sleep(Duration::from_millis(20)).await; - } - Err(CliError::Launch(format!( - "gateway did not become ready at {url}" - ))) -} - -fn codex_gateway_provider_config(gateway_url: &str) -> String { - // `wire_api="responses"` is the only value codex 0.130+ accepts; the `chat` value was - // removed (codex#7782). Codex transparent run therefore only works against upstreams that - // implement `/v1/responses` (api.openai.com or a Responses-compatible proxy). For other - // upstreams the user falls back to daemon mode and points codex directly at its configured - // upstream — we observe hooks but not LLM calls. - // - // `requires_openai_auth=true` so Codex's `resolve_provider_auth` (`codex-rs/model-provider/ - // src/auth.rs`) attaches credentials via `BearerAuthProvider`. When the auth mode is - // `Chatgpt` the token is an OAuth JWT or Codex access token; when `ApiKey` it is the - // `OPENAI_API_KEY` value. - // The gateway inspects the inbound `Authorization` header: if `OPENAI_API_KEY` is set in the - // environment the ChatGPT token is replaced (see `alignment::gateway_forward_headers` and - // `gateway.rs::inject_provider_auth`); otherwise it is forwarded to the ChatGPT backend. - format!( - "model_providers.nemo-relay-openai={{name=\"NeMo Relay OpenAI\",base_url={},wire_api=\"responses\",requires_openai_auth=true,supports_websockets=false}}", - toml_string(gateway_url) - ) -} - -// Appends one horizontal border line in NVIDIA green when color is enabled, otherwise plain -// ASCII-compatible box-drawing. -fn push_status_border( - output: &mut String, - left: char, - right: char, - inner_width: usize, - color: bool, -) { - let dashes = "─".repeat(inner_width); - if color { - output.push_str(&format!("\x1b[38;5;112m{left}{dashes}{right}\x1b[0m\n")); - } else { - output.push_str(&format!("{left}{dashes}{right}\n")); - } -} - -// Returns the absolute path of the running gateway binary so injected hooks can find it -// without relying on the user's `PATH`. Spawned hook subprocesses inherit the agent's -// environment; in transparent run, the dev/install location of the gateway is rarely on -// `PATH`, which would cause hooks to exit with status 127 (command not found). Falls back -// to the bare name when `current_exe` is unavailable so behavior degrades to the previous -// install-style assumption rather than failing to launch. -fn transparent_hook_executable() -> String { - std::env::current_exe() - .ok() - .and_then(|path| { - path.to_str().map(|s| { - #[cfg(windows)] - { - s.replace('\\', "/") - } - #[cfg(not(windows))] - { - s.to_owned() - } - }) - }) - .unwrap_or_else(|| "nemo-relay".to_string()) -} - -// Appends the running gateway binary's directory to the child agent PATH. Transparent hooks use -// the absolute executable path when possible, but adding the directory also covers hook loaders or -// user-managed hook commands that resolve `nemo-relay` through PATH inside the launched agent. Keep -// user PATH precedence intact so normal agent tool resolution does not change. -fn path_with_transparent_hook_dir() -> Option { - let dir = std::env::current_exe() - .ok() - .and_then(|path| path.parent().map(Path::to_path_buf))?; - let mut paths: Vec = std::env::var_os("PATH") - .as_deref() - .map(std::env::split_paths) - .into_iter() - .flatten() - .collect(); - if !paths.iter().any(|path| path == &dir) { - paths.push(dir); - } - std::env::join_paths(paths) - .ok() - .map(|path| path.to_string_lossy().into_owned()) -} - -// Inserts generated agent flags immediately after the last argv element that looks like the agent -// executable. Falling back to index 0 keeps wrapper commands usable by inserting after the first -// word when the agent cannot be found later in argv. -fn insert_after_agent( - argv: &mut Vec, - agent: CodingAgent, - args: impl IntoIterator, -) { - let index = argv - .iter() - .enumerate() - .filter_map(|(index, arg)| (CodingAgent::infer(arg) == Some(agent)).then_some(index)) - .next_back() - .unwrap_or(0); - argv.splice(index + 1..index + 1, args); -} - -// Writes pretty JSON hook config to a path whose parent has already been created by the caller. -// Serialization errors are converted to launch errors to keep temporary setup failures contextual. -fn write_hooks(path: &Path, hooks: Value) -> Result<(), CliError> { - std::fs::write( - path, - serde_json::to_vec_pretty(&hooks).map_err(|error| CliError::Launch(error.to_string()))?, - )?; - Ok(()) -} - -// Backs up an existing Hermes hook config before run-mode patching. -fn backup_existing_hermes_hooks(path: &Path) -> Result<(bool, Option), CliError> { - let had_original = path.exists(); - if !had_original { - return Ok((false, None)); - } - let backup = path.with_extension(format!("yaml.nemo-relay-run.bak.{}", timestamp()?)); - std::fs::copy(path, &backup)?; - Ok((true, Some(backup))) -} - -fn hermes_openai_base_url(gateway_url: &str) -> String { - format!("{}/v1", gateway_url.trim_end_matches('/')) -} - -// Creates the Hermes config parent directory when needed, merges generated gateway hooks with any -// existing YAML config, and temporarily selects Relay as Hermes's custom OpenAI-compatible -// provider. The caller restores the complete original config after the transparent run. -fn write_merged_hermes_config(path: &Path, gateway_url: &str) -> Result<(), CliError> { - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent)?; - } - let existing = match std::fs::read_to_string(path) { - Ok(raw) => raw, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(), - Err(error) => return Err(CliError::Io(error)), - }; - let contents = merge_hermes_config( - &existing, - generated_hooks( - CodingAgent::Hermes, - &hook_forward_command(&transparent_hook_executable(), CodingAgent::Hermes), - ), - )?; - let mut document = serde_yaml::from_str::(&contents).map_err(|error| { - CliError::Launch(format!("could not parse merged Hermes config: {error}")) - })?; - let root = document.as_mapping_mut().ok_or_else(|| { - CliError::Launch("merged Hermes config must be a YAML mapping".to_string()) - })?; - let model_key = serde_yaml::Value::String("model".into()); - let mut model = match root.remove(&model_key) { - Some(serde_yaml::Value::Mapping(model)) => model, - Some(serde_yaml::Value::String(default)) => serde_yaml::Mapping::from_iter([( - serde_yaml::Value::String("default".into()), - serde_yaml::Value::String(default), - )]), - Some(serde_yaml::Value::Null) | None => serde_yaml::Mapping::new(), - Some(_) => { - return Err(CliError::Launch( - "Hermes model config must be a string or mapping".into(), - )); - } - }; - model.insert( - serde_yaml::Value::String("provider".into()), - serde_yaml::Value::String("custom".into()), - ); - model.insert( - serde_yaml::Value::String("base_url".into()), - serde_yaml::Value::String(hermes_openai_base_url(gateway_url)), - ); - root.insert(model_key, serde_yaml::Value::Mapping(model)); - std::fs::write( - path, - serde_yaml::to_string(&document).map_err(|error| { - CliError::Launch(format!("could not render Hermes config: {error}")) - })?, - )?; - Ok(()) -} - -// Chooses the Hermes hook file that transparent run should patch. If setup recorded a specific -// path, reuse it; otherwise fall back to the Hermes home config file that Hermes itself reads. -fn hermes_hooks_path(configured: Option<&Path>) -> Result { - if let Some(path) = configured { - return Ok(path.to_path_buf()); - } - if let Some(home) = std::env::var_os("HERMES_HOME").filter(|value| !value.is_empty()) { - return Ok(PathBuf::from(home).join("config.yaml")); - } - let home = std::env::var_os("HOME") - .or_else(|| std::env::var_os("USERPROFILE")) - .ok_or_else(|| { - CliError::Launch("could not resolve home directory for Hermes hooks".into()) - })?; - Ok(PathBuf::from(home).join(".hermes").join("config.yaml")) -} - -fn restore_hook_file( - path: &Path, - backup_path: Option<&Path>, - had_original: bool, - label: &str, -) -> Result<(), CliError> { - match (backup_path, had_original) { - (Some(backup), true) => { - std::fs::copy(backup, path).map_err(|error| { - CliError::Launch(format!( - "failed to restore {label} hooks from {}: {error}", - backup.display() - )) - })?; - let _ = std::fs::remove_file(backup); - } - (_, false) if path.exists() => { - std::fs::remove_file(path).map_err(|error| { - CliError::Launch(format!( - "failed to remove temporary {label} hooks {}: {error}", - path.display() - )) - })?; - } - _ => {} - } - Ok(()) -} - -// Converts JSON hook groups into inline TOML arrays for Codex `--config` flags. The function -// preserves matchers when present and assumes generated hook groups contain one command hook. -fn hook_groups_toml(value: &Value) -> String { - let mut groups = Vec::new(); - for group in value.as_array().into_iter().flatten() { - let matcher = group - .get("matcher") - .and_then(Value::as_str) - .map(|matcher| format!("matcher={},", toml_string(matcher))) - .unwrap_or_default(); - let command = group["hooks"][0]["command"].as_str().unwrap_or_default(); - groups.push(format!( - "{{{matcher}hooks=[{{type=\"command\",command={},timeout=30}}]}}", - toml_string(command) - )); - } - format!("[{}]", groups.join(",")) -} - -// Escapes a Rust string as a TOML basic string for inline Codex configuration values. -fn toml_string(value: &str) -> String { - let escaped = value.replace('\\', "\\\\").replace('"', "\\\""); - format!("\"{escaped}\"") -} - -// Creates a timestamped directory under the OS temp directory. The timestamp suffix avoids -// collisions between concurrent transparent runs without keeping persistent state. -fn temp_dir(prefix: &str) -> Result { - let path = std::env::temp_dir().join(format!("{prefix}-{}", timestamp()?)); - std::fs::create_dir_all(&path)?; - Ok(path) -} - -// Returns a monotonic-enough wall-clock nanosecond stamp for temp and backup names. System time -// errors become launcher errors because paths cannot be safely generated without a timestamp. -fn timestamp() -> Result { - Ok(SystemTime::now() - .duration_since(UNIX_EPOCH) - .map_err(|error| CliError::Launch(error.to_string()))? - .as_nanos()) -} - -#[cfg(test)] -#[path = "../tests/coverage/launcher_tests.rs"] -mod tests; diff --git a/crates/cli/src/lib.rs b/crates/cli/src/lib.rs new file mode 100644 index 000000000..d292fb9de --- /dev/null +++ b/crates/cli/src/lib.rs @@ -0,0 +1,64 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Internal application library for the `nemo-relay` executable. + +mod agents; +mod banner; +mod bootstrap; +mod commands; +mod configuration; +mod diagnostics; +mod error; +mod events; +mod filesystem; +mod gateway; +mod hooks; +mod installation; +mod mcp; +mod mcp_environment; +mod plugins; +mod process; +mod server; +mod sessions; + +#[cfg(test)] +#[path = "../tests/coverage/shared/hook_assertions.rs"] +mod hook_assertions; + +#[cfg(test)] +#[path = "../tests/coverage/shared/test_support.rs"] +pub(crate) mod test_support; + +use std::process::ExitCode; + +/// Runs the `nemo-relay` process. +/// +/// This is an executable entrypoint, not a supported library API. +#[doc(hidden)] +pub fn run_cli() -> ExitCode { + mcp_environment::remove_unresolved_mcp_placeholders(); + let bootstrap_shutdown_token = take_bootstrap_shutdown_token(); + let runtime = match tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + { + Ok(runtime) => runtime, + Err(error) => { + eprintln!("failed to initialize async runtime: {error}"); + return ExitCode::FAILURE; + } + }; + runtime.block_on(commands::run(bootstrap_shutdown_token)) +} + +fn take_bootstrap_shutdown_token() -> Option { + let token = std::env::var(bootstrap::state::BOOTSTRAP_SHUTDOWN_TOKEN_ENV) + .ok() + .filter(|token| !token.is_empty()); + // SAFETY: this runs before the Tokio runtime and application threads are created. + unsafe { + std::env::remove_var(bootstrap::state::BOOTSTRAP_SHUTDOWN_TOKEN_ENV); + } + token +} diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index d5947b0b0..079955145 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -1,272 +1,10 @@ // SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -//! NeMo Relay coding-agent gateway CLI. - -mod adapters; -mod alignment; -mod banner; -mod completions_install; -mod config; -mod doctor; -mod error; -mod gateway; -mod installer; -mod json_path; -mod launcher; -mod model; -mod model_pricing; -mod plugin_install; -mod plugin_shim; -mod plugins; -mod server; -mod session; -mod setup; +//! Process entrypoint for the NeMo Relay coding-agent gateway. use std::process::ExitCode; -use clap::Parser; - -use crate::config::{ - Cli, CodingAgent, Command, CompletionsCommand, ConfigCommand, DoctorCommand, PluginsCommand, - PluginsSubcommand, PricingCommand, PricingSubcommand, ServerArgs, -}; - -#[tokio::main] -// Runs the async CLI entrypoint and converts any surfaced gateway error into a non-zero process -// exit. Errors are printed once here so subcommands can return structured errors without also -// owning process-level reporting. -async fn main() -> ExitCode { - match run().await { - Ok(code) => code, - Err(error) => { - let exit_code = if error.guardrail_rejection_reason().is_some() { - ExitCode::from(2) - } else { - ExitCode::FAILURE - }; - eprintln!("{error}"); - exit_code - } - } -} - -// Dispatches CLI subcommands while keeping the no-subcommand path as server mode. `run` inherits -// top-level server flags so transparent launch can share config parsing with daemon startup. -async fn run() -> Result { - let cli = Cli::parse(); - match cli.command { - Some(command) => run_command(command, &cli.server).await, - None => run_default(&cli.server).await, - } -} - -async fn run_command(command: Command, server: &ServerArgs) -> Result { - match command { - Command::HookForward(command) => { - installer::hook_forward(command).await?; - Ok(ExitCode::SUCCESS) - } - Command::PluginShim(command) => plugin_shim::run(command), - Command::Install(command) => plugin_install::install(command), - Command::Uninstall(command) => plugin_install::uninstall(command), - Command::Run(command) => launcher::run(command, Some(server)).await, - Command::Claude(command) => { - launcher::easy_path(CodingAgent::ClaudeCode, command, Some(server)).await - } - Command::Codex(command) => { - launcher::easy_path(CodingAgent::Codex, command, Some(server)).await - } - Command::Hermes(command) => { - launcher::easy_path(CodingAgent::Hermes, command, Some(server)).await - } - Command::Config(command) => run_config(command).await, - Command::Plugins(command) => run_plugins(command, server), - Command::ModelPricing(command) => run_pricing(command), - Command::Doctor(command) => run_doctor(command).await, - Command::Agents(command) => doctor::run_agents(command.json).await, - Command::Completions(command) => run_completions(command), - } -} - -async fn run_config(command: ConfigCommand) -> Result { - if command.reset { - setup::reset(command.agent)?; - } else { - setup::run(command.agent).await?; - } - Ok(ExitCode::SUCCESS) -} - -fn run_plugins(command: PluginsCommand, server: &ServerArgs) -> Result { - let json_context = command - .command - .json_context() - .map(|context| (context.command, context.target.map(str::to_owned))); - let json = json_context.is_some(); - let result = match command.command { - PluginsSubcommand::Edit(command) => plugins::edit(command), - PluginsSubcommand::Add(command) => plugins::lifecycle::add(command, server), - PluginsSubcommand::Validate(command) => plugins::lifecycle::validate(command, server), - PluginsSubcommand::List(command) => plugins::lifecycle::list(command, server), - PluginsSubcommand::Inspect(command) => plugins::lifecycle::inspect(command, server), - PluginsSubcommand::Enable(command) => plugins::lifecycle::enable(command, server), - PluginsSubcommand::Disable(command) => plugins::lifecycle::disable(command, server), - PluginsSubcommand::Remove(command) => plugins::lifecycle::remove(command, server), - }; - match result { - Ok(()) => Ok(ExitCode::SUCCESS), - Err(error) => { - if let Some(exit_code) = plugins::lifecycle::render_plugin_error(&error, json)? { - Ok(exit_code) - } else if json { - let (json_command, json_target) = json_context - .as_ref() - .expect("json plugin command context should exist when json output is enabled"); - plugins::lifecycle::render_generic_plugin_json_error( - json_command, - json_target.as_deref(), - &error.to_string(), - ) - } else { - Err(error) - } - } - } -} - -fn run_pricing(command: PricingCommand) -> Result { - match command.command { - PricingSubcommand::Validate(command) => model_pricing::validate(command)?, - PricingSubcommand::Init(command) => model_pricing::init(command)?, - PricingSubcommand::AddSource(command) => model_pricing::add_source(command)?, - PricingSubcommand::Resolve(command) => model_pricing::resolve(command)?, - } - Ok(ExitCode::SUCCESS) -} - -async fn run_doctor(command: DoctorCommand) -> Result { - if let Some(plugin) = command.plugin { - plugin_install::doctor(plugin, command.install_dir, command.json) - } else { - doctor::run_doctor(command.agent, command.json).await - } +fn main() -> ExitCode { + nemo_relay_cli::run_cli() } - -fn run_completions(command: CompletionsCommand) -> Result { - if command.install { - let path = completions_install::install(command.shell)?; - println!("✓ Installed completions: {}", path.display()); - } else { - generate_completions(command.shell)?; - } - Ok(ExitCode::SUCCESS) -} - -fn generate_completions(shell: Option) -> Result<(), error::CliError> { - generate_completions_to(shell, &mut std::io::stdout()) -} - -fn generate_completions_to( - shell: Option, - writer: &mut dyn std::io::Write, -) -> Result<(), error::CliError> { - let shell = shell.ok_or_else(|| { - error::CliError::Config( - "missing shell argument; pass a shell name (bash, zsh, fish, ...) or \ - use `--install` to auto-detect from $SHELL" - .into(), - ) - })?; - let mut clap_command = ::command(); - clap_complete::generate(shell, &mut clap_command, "nemo-relay", writer); - Ok(()) -} - -async fn run_default(server_args: &ServerArgs) -> Result { - // Bare `nemo-relay` with no subcommand: - // - If the user passed any daemon-specific flag (`--bind`, upstream URLs, ATIF dir, - // OpenInference endpoint), they obviously want the long-running gateway daemon — - // keep that path so existing scripts that explicitly invoke daemon mode stay - // compatible. - // - Otherwise — no flags, no subcommand — use the first-run path only when no config - // exists. Once configured, bare `nemo-relay` becomes a quick health check; explicit - // `nemo-relay config` remains the reconfiguration path. - if server_args.requested_daemon_mode() { - let resolved = config::resolve_server_config(server_args)?; - let dynamic_plugins = plugins::lifecycle::active_dynamic_plugin_components( - server_args.config.as_ref(), - &resolved, - )?; - server::serve_with_dynamic(resolved.gateway, dynamic_plugins).await?; - Ok(ExitCode::SUCCESS) - } else if config::any_config_file_exists() { - doctor::run_doctor(None, false).await - } else { - setup::run(None).await?; - Ok(ExitCode::SUCCESS) - } -} - -#[cfg(test)] -mod test_support { - #[must_use] - pub(crate) struct CwdTestScope { - _guard: std::sync::MutexGuard<'static, ()>, - prev: Option, - } - - impl CwdTestScope { - pub(crate) fn locked() -> Self { - Self { - _guard: lock_cwd(), - prev: None, - } - } - - pub(crate) fn enter(path: &std::path::Path) -> Self { - let guard = lock_cwd(); - let prev = std::env::current_dir().unwrap(); - std::env::set_current_dir(path).unwrap(); - Self { - _guard: guard, - prev: Some(prev), - } - } - } - - impl Drop for CwdTestScope { - fn drop(&mut self) { - if let Some(prev) = &self.prev - && let Err(error) = std::env::set_current_dir(prev) - { - CWD_RESTORE_FAILED.store(true, std::sync::atomic::Ordering::SeqCst); - if std::thread::panicking() { - eprintln!("failed to restore current_dir to {prev:?}: {error}"); - } else { - panic!("failed to restore current_dir to {prev:?}: {error}"); - } - } - } - } - - pub(crate) static CWD_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); - static CWD_RESTORE_FAILED: std::sync::atomic::AtomicBool = - std::sync::atomic::AtomicBool::new(false); - pub(crate) static ENV_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); - pub(crate) static PLUGIN_CONFIG_TEST_LOCK: tokio::sync::Mutex<()> = - tokio::sync::Mutex::const_new(()); - - fn lock_cwd() -> std::sync::MutexGuard<'static, ()> { - let guard = CWD_TEST_LOCK.lock().expect("CWD_TEST_LOCK poisoned"); - assert!( - !CWD_RESTORE_FAILED.load(std::sync::atomic::Ordering::SeqCst), - "current_dir restore failed in a previous test; aborting to prevent cross-test contamination", - ); - guard - } -} - -#[cfg(test)] -#[path = "../tests/coverage/main_tests.rs"] -mod tests; diff --git a/crates/cli/src/mcp/gateway.rs b/crates/cli/src/mcp/gateway.rs new file mode 100644 index 000000000..2e35860bc --- /dev/null +++ b/crates/cli/src/mcp/gateway.rs @@ -0,0 +1,475 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Acquisition and liveness lease for a shared coding-agent gateway. + +use std::net::SocketAddr; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Condvar, Mutex}; +use std::time::Duration; + +use crate::bootstrap::{GatewayEndpoint, GatewaySpec}; +use crate::error::CliError; +use crate::installation::generation::{ActiveGenerationGuard, InstallGeneration}; +use crate::server::GatewayOverrides; + +const UNHEALTHY_CONFIRMATIONS: u8 = 3; +const UNHEALTHY_CONFIRMATION_INTERVAL: Duration = Duration::from_millis(50); +const BORROWED_HEARTBEAT_INTERVAL: Duration = Duration::from_secs(30); + +pub(super) struct GatewayPlan { + spec: GatewaySpec, + heartbeat_interval: Duration, + generation: Option, + generation_guard: Option, +} + +impl GatewayPlan { + pub(super) async fn resolve(server_args: &GatewayOverrides) -> Result { + let captured = tokio::task::spawn_blocking(InstallGeneration::capture_guarded_from_env) + .await + .map_err(|error| { + CliError::Launch(format!("MCP generation capture task failed: {error}")) + })? + .map_err(CliError::Launch)?; + let (generation, generation_guard) = captured + .map(|(generation, guard)| (Some(generation), Some(guard))) + .unwrap_or((None, None)); + let bind = server_args.bind.unwrap_or_else(super::default_mcp_bind); + let launch = crate::bootstrap::resolve_plugin_gateway(server_args, bind)?; + let heartbeat_interval = + crate::bootstrap::plugin_heartbeat_interval().map_err(CliError::Launch)?; + Ok(Self { + spec: launch.gateway, + heartbeat_interval, + generation, + generation_guard, + }) + } + + pub(super) async fn acquire(mut self) -> Result { + let endpoint = acquire_gateway(self.spec.clone(), self.generation_guard.take()).await?; + let shutdown = Arc::new(LeaseShutdown::default()); + let monitor_shutdown = shutdown.clone(); + let monitor = tokio::spawn(async move { self.monitor(endpoint, monitor_shutdown).await }); + Ok(GatewayLease { monitor, shutdown }) + } + + async fn monitor( + self, + endpoint: crate::bootstrap::GatewayEndpoint, + shutdown: Arc, + ) -> Result<(), CliError> { + let health_spec = self.spec.clone(); + let restart_spec = self.spec.clone(); + let restart_generation = self.generation.clone(); + let verify_generation = self.generation; + maintain_gateway_instances_with_generation( + self.spec.bind(), + endpoint, + self.heartbeat_interval, + move |url, _expected_instance| { + let spec = health_spec.clone(); + async move { + tokio::task::spawn_blocking(move || spec.healthy_instance(&url)) + .await + .map_err(|error| { + CliError::Launch(format!("gateway heartbeat task failed: {error}")) + }) + } + }, + move |_bind, expected_instance| { + recover_gateway( + restart_spec.clone(), + restart_generation.clone(), + expected_instance, + shutdown.clone(), + ) + }, + move || { + let generation = verify_generation.clone(); + async move { verify_lifecycle_async(generation).await } + }, + ) + .await + } +} + +/// An active liveness lease. Dropping it stops heartbeats immediately. +pub(super) struct GatewayLease { + monitor: tokio::task::JoinHandle>, + shutdown: Arc, +} + +impl GatewayLease { + #[cfg(test)] + pub(super) fn test_pending() -> Self { + let monitor = tokio::spawn(std::future::pending::>()); + Self { + monitor, + shutdown: Arc::new(LeaseShutdown::default()), + } + } + + pub(super) async fn borrow( + gateway_url: String, + bootstrap_fingerprint: String, + ) -> Result { + Self::borrow_with_interval( + gateway_url, + bootstrap_fingerprint, + BORROWED_HEARTBEAT_INTERVAL, + ) + .await + } + + pub(super) async fn borrow_with_interval( + gateway_url: String, + bootstrap_fingerprint: String, + heartbeat_interval: Duration, + ) -> Result { + let expected_instance = authenticated_instance_id( + gateway_url.clone(), + bootstrap_fingerprint.clone(), + ) + .await? + .ok_or_else(|| { + CliError::Launch(format!( + "{} does not identify the authenticated NeMo Relay gateway owned by this transparent run", + crate::configuration::GATEWAY_URL_ENV + )) + })?; + let monitor = tokio::spawn(async move { + loop { + tokio::time::sleep(heartbeat_interval).await; + let current = + authenticated_instance_id(gateway_url.clone(), bootstrap_fingerprint.clone()) + .await?; + match current { + Some(instance) if instance == expected_instance => {} + Some(instance) => { + return Err(CliError::Launch(format!( + "transparent Relay gateway instance changed from {expected_instance} to {instance}" + ))); + } + None => { + return Err(CliError::Launch(format!( + "transparent Relay gateway at {gateway_url} is no longer available" + ))); + } + } + } + }); + Ok(Self { + monitor, + shutdown: Arc::new(LeaseShutdown::default()), + }) + } + + pub(super) async fn wait(&mut self) -> Result<(), CliError> { + (&mut self.monitor).await.map_err(|error| { + CliError::Launch(format!("gateway maintenance task failed: {error}")) + })? + } +} + +async fn authenticated_instance_id( + gateway_url: String, + bootstrap_fingerprint: String, +) -> Result, CliError> { + tokio::task::spawn_blocking(move || { + crate::gateway::client::authenticated_instance_id(&gateway_url, &bootstrap_fingerprint) + }) + .await + .map_err(|error| { + CliError::Launch(format!( + "transparent gateway verification task failed: {error}" + )) + }) +} + +impl Drop for GatewayLease { + fn drop(&mut self) { + self.shutdown.stop(); + self.monitor.abort(); + self.shutdown.wait_for_recovery(); + } +} + +#[derive(Default)] +struct LeaseShutdown { + stopped: AtomicBool, + recovery_count: Mutex, + recovery_finished: Condvar, +} + +impl LeaseShutdown { + fn start_recovery(self: &Arc) -> Result { + let mut count = self + .recovery_count + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if self.stopped.load(Ordering::Acquire) { + return Err(CliError::Launch("gateway lease is shutting down".into())); + } + *count += 1; + Ok(RecoveryGuard(self.clone())) + } + + fn stop(&self) { + self.stopped.store(true, Ordering::Release); + } + + fn wait_for_recovery(&self) { + let mut count = self + .recovery_count + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + while *count != 0 { + count = self + .recovery_finished + .wait(count) + .unwrap_or_else(std::sync::PoisonError::into_inner); + } + } +} + +struct RecoveryGuard(Arc); + +impl Drop for RecoveryGuard { + fn drop(&mut self) { + let mut count = self + .0 + .recovery_count + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + *count = count.saturating_sub(1); + self.0.recovery_finished.notify_all(); + } +} + +async fn acquire_gateway( + spec: GatewaySpec, + generation_guard: Option, +) -> Result { + tokio::task::spawn_blocking(move || { + let _generation_guard = generation_guard; + spec.acquire() + }) + .await + .map_err(|error| CliError::Launch(format!("gateway bootstrap task failed: {error}")))? + .map_err(CliError::Launch) +} + +async fn recover_gateway( + spec: GatewaySpec, + generation: Option, + expected_instance: String, + shutdown: Arc, +) -> Result { + let recovery_guard = shutdown.start_recovery()?; + tokio::task::spawn_blocking(move || { + let _recovery_guard = recovery_guard; + let _generation_guard = generation + .as_ref() + .map(InstallGeneration::guard_current) + .transpose()?; + spec.recover(&expected_instance) + }) + .await + .map_err(|error| CliError::Launch(format!("gateway recovery task failed: {error}")))? + .map_err(CliError::Launch) + .and_then(|endpoint| { + if shutdown.stopped.load(Ordering::Acquire) { + Err(CliError::Launch( + "gateway lease closed during recovery".into(), + )) + } else { + Ok(endpoint) + } + }) +} + +async fn verify_lifecycle_async(generation: Option) -> Result<(), CliError> { + loop { + let generation = generation.clone(); + let current = tokio::task::spawn_blocking(move || { + if let Some(generation) = generation.as_ref() + && !generation.try_verify_current()? + { + return Ok(false); + } + Ok(true) + }) + .await + .map_err(|error| { + CliError::Launch(format!("MCP lifecycle verification task failed: {error}")) + })? + .map_err(CliError::Launch)?; + if current { + return Ok(()); + } + tokio::time::sleep(Duration::from_millis(50)).await; + } +} + +#[cfg(test)] +pub(super) async fn maintain_gateway_with( + bind: SocketAddr, + gateway_url: String, + heartbeat_interval: Duration, + healthy: H, + restart: R, +) -> Result<(), CliError> +where + H: FnMut(String) -> HFuture, + HFuture: std::future::Future>, + R: FnMut(SocketAddr, String) -> RFuture, + RFuture: std::future::Future>, +{ + maintain_gateway_with_generation( + bind, + gateway_url, + heartbeat_interval, + healthy, + restart, + || async { Ok(()) }, + ) + .await +} + +#[cfg(test)] +pub(super) async fn maintain_gateway_with_generation( + bind: SocketAddr, + gateway_url: String, + heartbeat_interval: Duration, + mut healthy: H, + restart: R, + verify_generation: G, +) -> Result<(), CliError> +where + H: FnMut(String) -> HFuture, + HFuture: std::future::Future>, + R: FnMut(SocketAddr, String) -> RFuture, + RFuture: std::future::Future>, + G: FnMut() -> GFuture, + GFuture: std::future::Future>, +{ + maintain_gateway_instances_with_generation( + bind, + crate::bootstrap::GatewayEndpoint { + address: bind, + url: gateway_url, + instance_id: "test-initial-instance".into(), + }, + heartbeat_interval, + move |url, expected_instance| { + let probe = healthy(url); + async move { + probe + .await + .map(|is_healthy| is_healthy.then_some(expected_instance)) + } + }, + restart, + verify_generation, + ) + .await +} + +async fn maintain_gateway_instances_with_generation( + bind: SocketAddr, + mut endpoint: crate::bootstrap::GatewayEndpoint, + heartbeat_interval: Duration, + mut healthy: H, + mut restart: R, + mut verify_generation: G, +) -> Result<(), CliError> +where + H: FnMut(String, String) -> HFuture, + HFuture: std::future::Future, CliError>>, + R: FnMut(SocketAddr, String) -> RFuture, + RFuture: std::future::Future>, + G: FnMut() -> GFuture, + GFuture: std::future::Future>, +{ + let mut heartbeat = tokio::time::interval(heartbeat_interval); + let mut recovery = RecoveryState::new(endpoint.instance_id.clone()); + heartbeat.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + heartbeat.tick().await; + loop { + heartbeat.tick().await; + verify_generation().await?; + let mut observed_instance = None; + for confirmation in 0..UNHEALTHY_CONFIRMATIONS { + if confirmation > 0 { + tokio::time::sleep(UNHEALTHY_CONFIRMATION_INTERVAL).await; + verify_generation().await?; + } + observed_instance = + healthy(endpoint.url.clone(), recovery.instance_id().into()).await?; + // The health probe can queue or block while an integration replacement rotates the + // endpoint cohort. Revalidate before accepting either its instance or its failure so + // an old client cannot adopt the replacement across that asynchronous gap. + verify_generation().await?; + if observed_instance.is_some() { + break; + } + } + if let Some(instance_id) = observed_instance { + recovery.observe(instance_id)?; + continue; + } + recovery.require_restart()?; + verify_generation().await?; + let recovered = restart(bind, recovery.instance_id().into()).await?; + recovery.observe(recovered.instance_id.clone())?; + endpoint = recovered; + } +} + +struct RecoveryState { + instance_id: String, + recovered: bool, +} + +impl RecoveryState { + fn new(instance_id: String) -> Self { + Self { + instance_id, + recovered: false, + } + } + + fn instance_id(&self) -> &str { + &self.instance_id + } + + fn observe(&mut self, instance_id: String) -> Result<(), CliError> { + if instance_id == self.instance_id { + return Ok(()); + } + if self.recovered { + return Err(CliError::Launch( + "shared Relay gateway was replaced again after its coordinated restart".into(), + )); + } + self.instance_id = instance_id; + self.recovered = true; + Ok(()) + } + + fn require_restart(&self) -> Result<(), CliError> { + if self.recovered { + Err(CliError::Launch( + "shared Relay gateway became unhealthy after its coordinated restart".into(), + )) + } else { + Ok(()) + } + } +} + +#[cfg(test)] +#[path = "../../tests/coverage/shared/mcp_gateway_tests.rs"] +mod tests; diff --git a/crates/cli/src/mcp/mod.rs b/crates/cli/src/mcp/mod.rs new file mode 100644 index 000000000..6eecd7060 --- /dev/null +++ b/crates/cli/src/mcp/mod.rs @@ -0,0 +1,110 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Lifecycle-bound MCP stdio client for the shared native Relay gateway. + +mod gateway; +mod protocol; +mod session; +mod transport; + +use std::net::SocketAddr; +use std::path::Path; +use std::process::ExitCode; + +use serde_json::{Value, json}; + +use crate::error::CliError; +use crate::installation::generation::{GENERATION_FILE_ENV, GENERATION_TOKEN_ENV}; +use crate::server::GatewayOverrides; + +pub(crate) const SERVER_NAME: &str = "nemo-relay"; +const LAUNCH_ARGS: &[&str] = &["mcp"]; + +pub(crate) async fn run(server_args: &GatewayOverrides) -> Result { + if transparent_run_active() { + // An installed plugin can still be enabled inside `nemo-relay run`. In that process the + // wrapper already owns a healthy dynamic gateway, so this MCP instance authenticates and + // monitors it instead of launching the fixed persistent sidecar. + let gateway_url = std::env::var(crate::configuration::GATEWAY_URL_ENV).map_err(|_| { + CliError::Launch(format!( + "{} is required when {}=1", + crate::configuration::GATEWAY_URL_ENV, + crate::configuration::TRANSPARENT_RUN_ENV + )) + })?; + let bootstrap_fingerprint = + crate::configuration::transparent_gateway_fingerprint(&gateway_url); + let lease = gateway::GatewayLease::borrow(gateway_url, bootstrap_fingerprint).await?; + let frames = transport::spawn_stdin_reader()?; + session::run(lease, frames, tokio::io::stdout()).await?; + return Ok(ExitCode::SUCCESS); + } + // Starting the MCP process is the lifecycle boundary. Acquire the shared gateway before + // reading protocol frames so hosts can rely on process startup rather than their individual + // initialize and hook ordering. + let lease = gateway::GatewayPlan::resolve(server_args) + .await? + .acquire() + .await?; + let frames = transport::spawn_stdin_reader()?; + session::run(lease, frames, tokio::io::stdout()).await?; + Ok(ExitCode::SUCCESS) +} + +/// Builds the host-independent persistent MCP launch contract. +/// +/// Host adapters add only schema-specific activation and environment-forwarding fields. Keeping +/// the command, arguments, fixed gateway bind, and generation fence here ensures Codex, Claude +/// Code, and Hermes launch the same process. +pub(crate) fn persistent_server( + relay: &Path, + generation_file: &Path, + generation_token: &str, +) -> Value { + json!({ + "command": relay, + "args": LAUNCH_ARGS, + "env": { + "NEMO_RELAY_GATEWAY_BIND": crate::bootstrap::DEFAULT_BIND, + (GENERATION_FILE_ENV): generation_file, + (GENERATION_TOKEN_ENV): generation_token + } + }) +} + +fn transparent_run_active() -> bool { + std::env::var(crate::configuration::TRANSPARENT_RUN_ENV) + .ok() + .as_deref() + == Some("1") +} + +fn default_mcp_bind() -> SocketAddr { + crate::bootstrap::DEFAULT_BIND + .parse() + .expect("default MCP gateway bind is valid") +} + +#[cfg(test)] +async fn run_session(reader: R, writer: W) -> Result<(), CliError> +where + R: tokio::io::AsyncBufRead + Unpin, + W: tokio::io::AsyncWrite + Unpin, +{ + let lease = gateway::GatewayLease::test_pending(); + session::serve_with_lease(lease, reader, writer).await +} + +#[cfg(test)] +use gateway::{maintain_gateway_with, maintain_gateway_with_generation}; +#[cfg(test)] +use protocol::{MCP_PROTOCOL_VERSION, jsonrpc_error, response_for}; +#[cfg(test)] +use session::serve_stdio; +#[cfg(test)] +use transport::{MAX_MCP_FRAME_BYTES, read_bounded_frame}; + +#[cfg(test)] +#[path = "../../tests/coverage/shared/mcp_tests.rs"] +mod tests; diff --git a/crates/cli/src/mcp/protocol.rs b/crates/cli/src/mcp/protocol.rs new file mode 100644 index 000000000..1af75c8d4 --- /dev/null +++ b/crates/cli/src/mcp/protocol.rs @@ -0,0 +1,100 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Minimal MCP/JSON-RPC protocol implemented by the lifecycle client. + +use serde_json::{Value, json}; + +pub(super) const MCP_PROTOCOL_VERSION: &str = "2025-11-25"; +pub(super) const MCP_SUPPORTED_PROTOCOL_VERSIONS: &[&str] = &[MCP_PROTOCOL_VERSION, "2025-06-18"]; + +/// Result of decoding one newline-delimited MCP frame. +pub(super) struct FrameAction { + pub(super) response: Option, +} + +/// Parse a frame once and derive its protocol response. +pub(super) fn evaluate_frame(frame: &str) -> FrameAction { + match serde_json::from_str::(frame) { + Ok(message) => FrameAction { + response: response_for(&message), + }, + Err(_) => FrameAction { + response: Some(jsonrpc_error(Value::Null, -32700, "Parse error")), + }, + } +} + +pub(super) fn response_for(message: &Value) -> Option { + let raw_id = message.get("id"); + let response_id = raw_id + .filter(|id| valid_request_id(id)) + .cloned() + .unwrap_or(Value::Null); + if !message.is_object() || message.get("jsonrpc").and_then(Value::as_str) != Some("2.0") { + return Some(jsonrpc_error(response_id, -32600, "Invalid Request")); + } + let method = message.get("method").and_then(Value::as_str); + if raw_id.is_some_and(|id| !valid_request_id(id)) { + return Some(jsonrpc_error(Value::Null, -32600, "Invalid Request")); + } + if method.is_none() { + return Some(jsonrpc_error(response_id, -32600, "Invalid Request")); + } + let id = raw_id?.clone(); + match method { + Some("initialize") => { + let Some(requested_protocol) = message + .pointer("/params/protocolVersion") + .and_then(Value::as_str) + else { + return Some(jsonrpc_error(id, -32602, "Missing protocolVersion")); + }; + let protocol_version = if MCP_SUPPORTED_PROTOCOL_VERSIONS.contains(&requested_protocol) + { + requested_protocol + } else { + MCP_PROTOCOL_VERSION + }; + Some(json!({ + "jsonrpc": "2.0", + "id": id, + "result": { + "protocolVersion": protocol_version, + "capabilities": {}, + "serverInfo": { + "name": "nemo-relay", + "version": env!("CARGO_PKG_VERSION") + } + } + })) + } + Some("tools/list") => Some(json!({ + "jsonrpc": "2.0", + "id": id, + "result": { "tools": [] } + })), + Some("ping") => Some(json!({ + "jsonrpc": "2.0", + "id": id, + "result": {} + })), + Some(_) => Some(jsonrpc_error(id, -32601, "Method not found")), + None => Some(jsonrpc_error(id, -32600, "Invalid Request")), + } +} + +fn valid_request_id(id: &Value) -> bool { + id.is_string() || id.as_i64().is_some() || id.as_u64().is_some() +} + +pub(super) fn jsonrpc_error(id: Value, code: i64, message: &str) -> Value { + json!({ + "jsonrpc": "2.0", + "id": id, + "error": { + "code": code, + "message": message + } + }) +} diff --git a/crates/cli/src/mcp/session.rs b/crates/cli/src/mcp/session.rs new file mode 100644 index 000000000..f35127c0e --- /dev/null +++ b/crates/cli/src/mcp/session.rs @@ -0,0 +1,82 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! MCP stdio session coordinated with a shared-gateway liveness lease. + +use tokio::io::{AsyncWrite, AsyncWriteExt}; + +use super::gateway::GatewayLease; +use super::protocol::{FrameAction, evaluate_frame}; +use super::transport::FrameReceiver; +use crate::error::CliError; + +pub(super) async fn run( + mut lease: GatewayLease, + mut frames: FrameReceiver, + mut writer: W, +) -> Result<(), CliError> +where + W: AsyncWrite + Unpin, +{ + loop { + let received = tokio::select! { + frame = frames.recv() => frame, + result = lease.wait() => return result, + }; + let Some(frame) = received else { + return Ok(()); + }; + let frame = frame?; + let action = evaluate_frame(&frame); + write_response(action, &mut writer).await?; + } +} + +async fn write_response(action: FrameAction, writer: &mut W) -> Result<(), CliError> +where + W: AsyncWrite + Unpin, +{ + let Some(response) = action.response else { + return Ok(()); + }; + let mut encoded = serde_json::to_vec(&response) + .map_err(|error| CliError::Launch(format!("failed to encode MCP response: {error}")))?; + encoded.push(b'\n'); + writer.write_all(&encoded).await?; + writer.flush().await?; + Ok(()) +} + +#[cfg(test)] +pub(super) async fn serve_stdio(mut reader: R, mut writer: W) -> Result<(), CliError> +where + R: tokio::io::AsyncBufRead + Unpin, + W: AsyncWrite + Unpin, +{ + use tokio::io::AsyncBufReadExt; + + let mut line = String::new(); + loop { + line.clear(); + if reader.read_line(&mut line).await? == 0 { + return Ok(()); + } + write_response(evaluate_frame(&line), &mut writer).await?; + } +} + +#[cfg(test)] +pub(super) async fn serve_with_lease( + mut lease: GatewayLease, + reader: R, + writer: W, +) -> Result<(), CliError> +where + R: tokio::io::AsyncBufRead + Unpin, + W: AsyncWrite + Unpin, +{ + tokio::select! { + result = serve_stdio(reader, writer) => result, + result = lease.wait() => result, + } +} diff --git a/crates/cli/src/mcp/transport.rs b/crates/cli/src/mcp/transport.rs new file mode 100644 index 000000000..9cf2ec374 --- /dev/null +++ b/crates/cli/src/mcp/transport.rs @@ -0,0 +1,71 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Bounded newline framing for MCP stdio. + +use crate::error::CliError; + +pub(super) const MAX_MCP_FRAME_BYTES: usize = 1024 * 1024; +pub(super) type FrameReceiver = tokio::sync::mpsc::Receiver>; + +/// Read stdin on a plain thread so EOF remains dependable across Tokio platforms. +pub(super) fn spawn_stdin_reader() -> Result { + let (sender, receiver) = tokio::sync::mpsc::channel(16); + std::thread::Builder::new() + .name("nemo-relay-mcp-stdin".into()) + .spawn(move || { + let stdin = std::io::stdin(); + let mut stdin = stdin.lock(); + loop { + let mut frame = Vec::new(); + match read_bounded_frame(&mut stdin, &mut frame, MAX_MCP_FRAME_BYTES) { + Ok(0) => return, + Ok(_) => { + let line = String::from_utf8(frame).map_err(|error| { + std::io::Error::new(std::io::ErrorKind::InvalidData, error) + }); + if sender.blocking_send(line).is_err() { + return; + } + } + Err(error) => { + let _ = sender.blocking_send(Err(error)); + return; + } + } + } + }) + .map_err(|error| CliError::Launch(format!("failed to start MCP stdin reader: {error}")))?; + Ok(receiver) +} + +pub(super) fn read_bounded_frame( + reader: &mut R, + frame: &mut Vec, + limit: usize, +) -> std::io::Result { + loop { + let (consumed, complete) = { + let available = reader.fill_buf()?; + if available.is_empty() { + return Ok(frame.len()); + } + let consumed = available + .iter() + .position(|byte| *byte == b'\n') + .map_or(available.len(), |index| index + 1); + if frame.len().saturating_add(consumed) > limit { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("MCP frame exceeds the {limit}-byte limit"), + )); + } + frame.extend_from_slice(&available[..consumed]); + (consumed, available[consumed - 1] == b'\n') + }; + reader.consume(consumed); + if complete { + return Ok(frame.len()); + } + } +} diff --git a/crates/cli/src/mcp_environment.rs b/crates/cli/src/mcp_environment.rs new file mode 100644 index 000000000..ef27f1890 --- /dev/null +++ b/crates/cli/src/mcp_environment.rs @@ -0,0 +1,256 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Environment names shared by MCP generation and gateway compatibility checks. + +use std::collections::BTreeSet; + +use serde_json::Value; + +use crate::installation::generation::{GENERATION_FILE_ENV, GENERATION_TOKEN_ENV}; + +const BASE_MCP_ENV_VARS: &[&str] = &[ + "ALL_PROXY", + "ANTHROPIC_API_KEY", + "APPDATA", + "AWS_ACCESS_KEY_ID", + "AWS_ALLOW_HTTP", + "AWS_CA_BUNDLE", + "AWS_CONFIG_FILE", + "AWS_CONTAINER_AUTHORIZATION_TOKEN", + "AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE", + "AWS_CONTAINER_CREDENTIALS_FULL_URI", + "AWS_CONTAINER_CREDENTIALS_RELATIVE_URI", + "AWS_DEFAULT_REGION", + "AWS_EC2_METADATA_DISABLED", + "AWS_ENDPOINT_URL", + "AWS_PROFILE", + "AWS_REGION", + "AWS_ROLE_ARN", + "AWS_ROLE_SESSION_NAME", + "AWS_SDK_LOAD_CONFIG", + "AWS_SECRET_ACCESS_KEY", + "AWS_SESSION_TOKEN", + "AWS_SHARED_CREDENTIALS_FILE", + "AWS_STS_REGIONAL_ENDPOINTS", + "AWS_WEB_IDENTITY_TOKEN_FILE", + "HOME", + "HTTPS_PROXY", + "HTTP_PROXY", + "LOCALAPPDATA", + "NEMO_RELAY_ANTHROPIC_BASE_URL", + "NEMO_RELAY_GATEWAY_URL", + "NEMO_RELAY_MAX_HOOK_PAYLOAD_BYTES", + "NEMO_RELAY_MAX_PASSTHROUGH_BODY_BYTES", + "NEMO_RELAY_OPENAI_BASE_URL", + "NEMO_RELAY_PLUGIN_IDLE_TIMEOUT_SECS", + "NEMO_RELAY_PYTHON", + "NEMO_RELAY_TRANSPARENT_RUN", + "NO_PROXY", + "OPENAI_API_KEY", + "OTEL_EXPORTER_OTLP_COMPRESSION", + "OTEL_EXPORTER_OTLP_ENDPOINT", + "OTEL_EXPORTER_OTLP_HEADERS", + "OTEL_EXPORTER_OTLP_PROTOCOL", + "OTEL_EXPORTER_OTLP_TIMEOUT", + "OTEL_EXPORTER_OTLP_TRACES_COMPRESSION", + "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", + "OTEL_EXPORTER_OTLP_TRACES_HEADERS", + "OTEL_EXPORTER_OTLP_TRACES_PROTOCOL", + "OTEL_EXPORTER_OTLP_TRACES_TIMEOUT", + "OTEL_RESOURCE_ATTRIBUTES", + "OTEL_SDK_DISABLED", + "OTEL_SERVICE_NAME", + "SSL_CERT_DIR", + "SSL_CERT_FILE", + "TEMP", + "TMPDIR", + "USERPROFILE", + "XDG_CONFIG_HOME", + "XDG_RUNTIME_DIR", + "all_proxy", + "http_proxy", + "https_proxy", + "no_proxy", +]; + +const BLOCKED_MCP_ENV_VARS: &[&str] = &[ + "NEMO_RELAY_BINDING_KIND", + "NEMO_RELAY_BOOTSTRAP_AGENT", + "NEMO_RELAY_BOOTSTRAP_FINGERPRINT", + "NEMO_RELAY_BOOTSTRAP_STATE_DIR", + "NEMO_RELAY_BOOTSTRAP_SHUTDOWN_TOKEN", + "NEMO_RELAY_CONFIG_SCOPE", + "NEMO_RELAY_FAIL_CLOSED", + "NEMO_RELAY_GATEWAY_BIND", + "NEMO_RELAY_HOST_SOCKET", + "NEMO_RELAY_MCP_GENERATION", + "NEMO_RELAY_MCP_GENERATION_FILE", + "NEMO_RELAY_NATIVE_ABI_VERSION", + "NEMO_RELAY_PLUGIN_BINARY", + "NEMO_RELAY_PLUGIN_BIND", + "NEMO_RELAY_SIDECAR_JOB_NAME", + "NEMO_RELAY_PLUGIN_CONFIG_PATH", + "NEMO_RELAY_PLUGIN_GATEWAY_URL", + "NEMO_RELAY_PLUGIN_ID", + "NEMO_RELAY_RUNTIME_OWNER", + "NEMO_RELAY_WORKER_ENDPOINT_FILE", + "NEMO_RELAY_WORKER_ID", + "NEMO_RELAY_WORKER_SOCKET", + "NEMO_RELAY_WORKER_TOKEN", +]; + +pub(crate) fn forwarded_names( + environment: impl IntoIterator, + config: Option<&Value>, +) -> Vec { + forwarded_names_for_platform(environment, config, cfg!(windows)) +} + +pub(crate) fn forwarded_names_for_platform( + environment: impl IntoIterator, + config: Option<&Value>, + windows: bool, +) -> Vec { + let mut names = BTreeSet::new(); + for name in BASE_MCP_ENV_VARS { + insert_name(&mut names, (*name).to_string(), windows); + } + for name in environment { + if prefix_allowed(&name, windows) && !blocked(&name) { + insert_name(&mut names, name, windows); + } + } + if let Some(config) = config { + collect_config_names(config, &mut names, windows); + } + names.into_iter().collect() +} + +/// Removes unresolved `${NAME}` values injected by MCP hosts before CLI parsing. +/// +/// Hermes forwards environment names through placeholder values rather than a separate +/// `env_vars` list. When a variable is absent, Hermes preserves the self-placeholder. Relay must +/// treat that value as unset before clap reads numeric or socket-valued environment options. The +/// generation fence scopes this cleanup to managed persistent MCP launches; internal variables +/// remain untouched so malformed or retired generation identities fail closed during validation. +pub(crate) fn remove_unresolved_mcp_placeholders() { + if std::env::var_os(GENERATION_FILE_ENV).is_none() + || std::env::var_os(GENERATION_TOKEN_ENV).is_none() + { + return; + } + let unresolved = std::env::vars_os() + .filter_map(|(name, value)| { + let name_text = name.to_str()?; + let value = value.to_str()?; + (!blocked(name_text) + && unresolved_self_placeholder_for_platform(name_text, value, cfg!(windows))) + .then_some(name) + }) + .collect::>(); + for name in unresolved { + // SAFETY: The synchronous CLI entrypoint calls this before constructing the Tokio runtime, + // so no other thread can read or write the process environment concurrently. + unsafe { std::env::remove_var(name) }; + } +} + +pub(crate) fn forwarded_names_match_for_platform(left: &str, right: &str, windows: bool) -> bool { + if windows { + left.eq_ignore_ascii_case(right) + } else { + left == right + } +} + +/// Returns whether a name could have been captured from an earlier process environment. +/// +/// Arbitrary config-referenced names remain in the current expected set. Historical extras are +/// therefore limited to the static allowlist and approved dynamic prefixes. +pub(crate) fn previously_forwardable_name_for_platform(name: &str, windows: bool) -> bool { + !blocked(name) + && (BASE_MCP_ENV_VARS + .iter() + .any(|base| forwarded_names_match_for_platform(name, base, windows)) + || prefix_allowed(name, windows)) +} + +pub(crate) fn unresolved_self_placeholder_for_platform( + name: &str, + value: &str, + windows: bool, +) -> bool { + value + .strip_prefix("${") + .and_then(|value| value.strip_suffix('}')) + .is_some_and(|placeholder| forwarded_names_match_for_platform(name, placeholder, windows)) +} + +fn prefix_allowed(name: &str, windows: bool) -> bool { + ["NEMO_RELAY_", "OTEL_", "AWS_"].iter().any(|prefix| { + if windows { + starts_with_ignore_ascii_case(name, prefix) + } else { + name.starts_with(prefix) + } + }) +} + +fn blocked(name: &str) -> bool { + BLOCKED_MCP_ENV_VARS + .iter() + .any(|blocked| name.eq_ignore_ascii_case(blocked)) + || starts_with_ignore_ascii_case(name, "NEMO_RELAY_TEST_") +} + +fn starts_with_ignore_ascii_case(value: &str, prefix: &str) -> bool { + value + .get(..prefix.len()) + .is_some_and(|candidate| candidate.eq_ignore_ascii_case(prefix)) +} + +fn insert_name(names: &mut BTreeSet, name: String, windows: bool) { + if !windows + || !names + .iter() + .any(|existing| existing.eq_ignore_ascii_case(&name)) + { + names.insert(name); + } +} + +fn collect_config_names(value: &Value, names: &mut BTreeSet, windows: bool) { + match value { + Value::Object(object) => { + for (key, value) in object { + match key.as_str() { + "header_env" => { + if let Some(headers) = value.as_object() { + for name in headers.values().filter_map(Value::as_str) { + if !name.is_empty() && !blocked(name) { + insert_name(names, name.to_owned(), windows); + } + } + } + } + "secret_access_key_var" | "session_token_var" => { + if let Some(name) = value.as_str() + && !name.is_empty() + && !blocked(name) + { + insert_name(names, name.to_owned(), windows); + } + } + _ => collect_config_names(value, names, windows), + } + } + } + Value::Array(values) => { + for value in values { + collect_config_names(value, names, windows); + } + } + _ => {} + } +} diff --git a/crates/cli/src/plugin_install/marketplace.rs b/crates/cli/src/plugin_install/marketplace.rs deleted file mode 100644 index 0169fd435..000000000 --- a/crates/cli/src/plugin_install/marketplace.rs +++ /dev/null @@ -1,151 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -//! Generated local marketplace and plugin manifest files. - -use std::fs; - -use serde_json::{Value, json}; - -use crate::config::{CodingAgent, PluginHost}; -use crate::installer::generated_hooks; - -use super::state::{PluginInstallOptions, PluginLayout, remove_path, write_json}; -use super::{MARKETPLACE_NAME, PLUGIN_NAME}; - -pub(super) fn write_plugin_marketplace( - host: PluginHost, - layout: &PluginLayout, - options: &PluginInstallOptions, -) -> Result<(), String> { - if options.dry_run { - println!("write {}", layout.marketplace_manifest.display()); - println!("write {}", layout.plugin_manifest.display()); - if plugin_has_hooks_template(host) { - println!("write {}", layout.hooks_path.display()); - } - return Ok(()); - } - remove_path(&layout.plugin_root, options)?; - fs::create_dir_all( - layout - .plugin_root - .parent() - .unwrap_or(&layout.marketplace_root), - ) - .map_err(|error| format!("failed to create {}: {error}", layout.plugin_root.display()))?; - if plugin_has_hooks_template(host) { - fs::create_dir_all(layout.hooks_path.parent().unwrap_or(&layout.plugin_root)).map_err( - |error| format!("failed to create {}: {error}", layout.hooks_path.display()), - )?; - } - write_json(&layout.marketplace_manifest, &marketplace_manifest(host))?; - write_json(&layout.plugin_manifest, &plugin_manifest(host))?; - if plugin_has_hooks_template(host) { - write_json(&layout.hooks_path, &plugin_hooks(host))?; - } - Ok(()) -} - -pub(super) fn marketplace_manifest(host: PluginHost) -> Value { - match host { - PluginHost::Codex => json!({ - "name": MARKETPLACE_NAME, - "interface": { - "displayName": "NeMo Relay Local" - }, - "plugins": [{ - "name": PLUGIN_NAME, - "source": { - "source": "local", - "path": "./plugins/nemo-relay-plugin" - }, - "policy": { - "installation": "AVAILABLE", - "authentication": "ON_INSTALL" - }, - "category": "Coding" - }] - }), - PluginHost::ClaudeCode => json!({ - "name": MARKETPLACE_NAME, - "metadata": { - "description": "Local NeMo Relay plugins for Claude Code." - }, - "owner": { - "name": "NVIDIA Corporation and Affiliates", - "email": "noreply@nvidia.com" - }, - "plugins": [{ - "name": PLUGIN_NAME, - "description": "Forward Claude Code lifecycle hooks to a local NeMo Relay sidecar.", - "source": "./plugins/nemo-relay-plugin", - "category": "development" - }] - }), - PluginHost::All => unreachable!("all is expanded before manifest generation"), - } -} - -pub(super) fn plugin_manifest(host: PluginHost) -> Value { - let description = match host { - PluginHost::Codex => "Codex hooks that forward canonical lifecycle payloads to nemo-relay.", - PluginHost::ClaudeCode => { - "Claude Code hooks that forward canonical lifecycle payloads to nemo-relay." - } - PluginHost::All => unreachable!("all is expanded before manifest generation"), - }; - let keywords = match host { - PluginHost::Codex => json!(["nemo-relay", "codex", "hooks", "observability"]), - PluginHost::ClaudeCode => json!(["nemo-relay", "claude-code", "hooks", "observability"]), - PluginHost::All => unreachable!("all is expanded before manifest generation"), - }; - let mut manifest = json!({ - "name": PLUGIN_NAME, - "version": env!("CARGO_PKG_VERSION"), - "description": description, - "author": { - "name": "NVIDIA Corporation and Affiliates", - "url": "https://github.com/NVIDIA/NeMo-Relay" - }, - "homepage": "https://github.com/NVIDIA/NeMo-Relay", - "repository": "https://github.com/NVIDIA/NeMo-Relay", - "license": "Apache-2.0", - "keywords": keywords - }); - if matches!(host, PluginHost::Codex) { - manifest["interface"] = json!({ - "displayName": "NeMo Relay Plugin", - "shortDescription": "Forward Codex lifecycle hooks to a local NeMo Relay sidecar.", - "longDescription": "Installs command hooks that preserve Codex hook payloads and forward them to nemo-relay for agent, subagent, tool, and lifecycle observability. Full LLM capture also requires sidecar provider routing.", - "developerName": "NVIDIA", - "category": "Coding", - "capabilities": ["Read"], - "defaultPrompt": ["Capture this Codex session with NeMo Relay observability."], - "websiteURL": "https://github.com/NVIDIA/NeMo-Relay", - "brandColor": "#76B900" - }); - } - manifest -} - -pub(super) fn plugin_hooks(host: PluginHost) -> Value { - match host { - PluginHost::Codex => { - generated_hooks(CodingAgent::Codex, "nemo-relay plugin-shim hook codex") - } - PluginHost::ClaudeCode => generated_hooks( - CodingAgent::ClaudeCode, - "nemo-relay plugin-shim hook claude", - ), - PluginHost::All => unreachable!("all is expanded before hook generation"), - } -} - -pub(super) fn plugin_has_hooks_template(host: PluginHost) -> bool { - match host { - PluginHost::Codex => false, - PluginHost::ClaudeCode => true, - PluginHost::All => unreachable!("all is expanded before hook generation"), - } -} diff --git a/crates/cli/src/plugin_install/mod.rs b/crates/cli/src/plugin_install/mod.rs deleted file mode 100644 index 886e12b2c..000000000 --- a/crates/cli/src/plugin_install/mod.rs +++ /dev/null @@ -1,822 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -//! Local marketplace installer for Claude Code and Codex plugins. - -mod host; -mod marketplace; -mod setup; -mod state; - -use std::path::{Path, PathBuf}; -use std::process::ExitCode; -use std::sync::mpsc::{self, Receiver}; -use std::time::{Duration, Instant}; - -use serde::Serialize; -use serde_json::{Value, json}; - -use crate::config::{InstallCommand, PluginHost, UninstallCommand}; -use crate::error::CliError; - -use host::{ - CommandRunner, RealCommandRunner, host_registration_report, require_host_cli, require_relay, - run_host_marketplace_registration, run_host_marketplace_removal, run_host_plugin_registration, - run_host_plugin_removal, validate_relay_plugin_shim, -}; -use marketplace::{marketplace_manifest, plugin_manifest, write_plugin_marketplace}; -use setup::{ - PluginSetupRunner, RealPluginSetupRunner, run_plugin_doctor, run_plugin_doctor_json, - run_plugin_setup, run_plugin_uninstall, -}; -use state::{ - CanonicalizeOrSelf, HostRegistrationProgress, HostSelectionMode, PluginInstallOptions, - PluginLayout, PluginState, default_install_dir, mark_plugin_setup_installed, read_state, - remove_path, state_path, write_state, write_state_for_host, -}; - -pub(super) const DEFAULT_GATEWAY_URL: &str = "http://127.0.0.1:47632"; -pub(super) const MARKETPLACE_NAME: &str = "nemo-relay-local"; -pub(super) const PLUGIN_NAME: &str = "nemo-relay-plugin"; -pub(super) const RELAY_COMMAND: &str = "nemo-relay"; -const DEFAULT_HOST_PLUGIN_READINESS_TIMEOUT: Duration = Duration::from_secs(5); - -/// One non-mutating readiness check for an installed coding-agent plugin. -/// -/// This is deliberately independent from the CLI doctor's status type so the installer can -/// expose its checks to both the focused and top-level doctor paths without coupling their -/// rendering concerns. -#[derive(Debug, Clone, Serialize)] -pub(crate) struct HostPluginReadinessCheck { - pub(crate) name: String, - pub(crate) ok: bool, - pub(crate) details: String, -} - -/// Readiness state for one persisted host-plugin installation. -#[derive(Debug, Clone, Serialize)] -pub(crate) struct HostPluginReadiness { - pub(crate) host: String, - pub(crate) remediation: String, - pub(crate) state_path: PathBuf, - pub(crate) marketplace: Option, - pub(crate) plugin: Option, - pub(crate) checks: Vec, - #[serde(skip_serializing)] - pub(crate) relay: Option, - #[serde(skip_serializing)] - pub(crate) host_plugin_registered: Option, - #[serde(skip_serializing)] - pub(crate) host_marketplace_registered: Option, - #[serde(skip_serializing)] - pub(crate) plugin_setup: Option, -} - -impl HostPluginReadiness { - pub(crate) fn ok(&self) -> bool { - self.checks.iter().all(|check| check.ok) - } - - fn push(&mut self, name: impl Into, result: Result) { - match result { - Ok(details) => self.checks.push(HostPluginReadinessCheck { - name: name.into(), - ok: true, - details, - }), - Err(details) => self.checks.push(HostPluginReadinessCheck { - name: name.into(), - ok: false, - details, - }), - } - } -} - -struct PendingHostPluginReadiness { - host: PluginHost, - state_path: PathBuf, - receiver: Receiver, -} - -/// Collects default-location host-plugin readiness without printing or mutating state. -/// -/// Only hosts with a persisted install-state record are included. This keeps ordinary -/// transparent-run users from failing the top-level doctor merely because they have not opted -/// into the persistent host-plugin workflow. -pub(crate) fn collect_default_host_plugin_readiness() -> Vec { - let install_dir = default_install_dir().canonicalize_or_self(); - let pending = [PluginHost::Codex, PluginHost::ClaudeCode] - .into_iter() - .filter(|host| state_path(*host, &install_dir).exists()) - .map(|host| spawn_default_host_plugin_readiness(host, install_dir.clone())) - .collect::>(); - let deadline = Instant::now() + DEFAULT_HOST_PLUGIN_READINESS_TIMEOUT; - pending - .into_iter() - .map(|pending| { - receive_host_plugin_readiness( - pending, - deadline.saturating_duration_since(Instant::now()), - ) - }) - .collect() -} - -fn spawn_default_host_plugin_readiness( - host: PluginHost, - install_dir: PathBuf, -) -> PendingHostPluginReadiness { - let state_path = state_path(host, &install_dir); - let (sender, receiver) = mpsc::sync_channel(1); - std::thread::spawn(move || { - let options = PluginInstallOptions { - install_dir, - force: false, - dry_run: false, - skip_doctor: true, - }; - let runner = RealCommandRunner; - let setup_runner = RealPluginSetupRunner; - let readiness = collect_host_plugin_readiness(host, &options, &runner, &setup_runner); - let _ = sender.send(readiness); - }); - PendingHostPluginReadiness { - host, - state_path, - receiver, - } -} - -fn receive_host_plugin_readiness( - pending: PendingHostPluginReadiness, - timeout: Duration, -) -> HostPluginReadiness { - match pending.receiver.recv_timeout(timeout) { - Ok(readiness) => readiness, - Err(mpsc::RecvTimeoutError::Timeout) => failed_host_plugin_readiness( - pending.host, - pending.state_path, - "timed out while collecting host-plugin readiness", - ), - Err(mpsc::RecvTimeoutError::Disconnected) => failed_host_plugin_readiness( - pending.host, - pending.state_path, - "host-plugin readiness collector stopped unexpectedly", - ), - } -} - -fn failed_host_plugin_readiness( - host: PluginHost, - state_path: PathBuf, - details: impl Into, -) -> HostPluginReadiness { - let layout = PluginLayout::new(host, state_path.parent().unwrap_or_else(|| Path::new("."))); - let mut readiness = HostPluginReadiness { - host: host_arg(host).to_string(), - remediation: format!("nemo-relay install {} --force", host_arg(host)), - state_path, - marketplace: Some(layout.marketplace_root), - plugin: Some(layout.plugin_root), - checks: Vec::new(), - relay: None, - host_plugin_registered: None, - host_marketplace_registered: None, - plugin_setup: None, - }; - readiness.push("Host readiness", Err(details.into())); - readiness -} - -pub(crate) fn install(command: InstallCommand) -> Result { - let options = PluginInstallOptions { - install_dir: command - .install_dir - .unwrap_or_else(default_install_dir) - .canonicalize_or_self(), - force: command.force, - dry_run: command.dry_run, - skip_doctor: command.skip_doctor, - }; - run_for_hosts( - command.host, - HostSelectionMode::Install, - &options, - |host, options, runner, setup_runner| install_host(host, options, runner, setup_runner), - ) -} - -pub(crate) fn uninstall(command: UninstallCommand) -> Result { - let options = PluginInstallOptions { - install_dir: command - .install_dir - .unwrap_or_else(default_install_dir) - .canonicalize_or_self(), - force: false, - dry_run: command.dry_run, - skip_doctor: true, - }; - run_for_hosts( - command.host, - HostSelectionMode::InstalledState, - &options, - |host, options, runner, setup_runner| uninstall_host(host, options, runner, setup_runner), - ) -} - -pub(crate) fn doctor( - host: PluginHost, - install_dir: Option, - json: bool, -) -> Result { - let options = PluginInstallOptions { - install_dir: install_dir - .unwrap_or_else(default_install_dir) - .canonicalize_or_self(), - force: false, - dry_run: false, - skip_doctor: true, - }; - if json { - return doctor_json(host, &options); - } - run_for_hosts( - host, - HostSelectionMode::InstalledState, - &options, - |host, options, runner, setup_runner| doctor_host(host, options, runner, setup_runner), - ) -} - -fn run_for_hosts( - host: PluginHost, - mode: HostSelectionMode, - options: &PluginInstallOptions, - mut action: F, -) -> Result -where - F: FnMut( - PluginHost, - &PluginInstallOptions, - &dyn CommandRunner, - &dyn PluginSetupRunner, - ) -> Result<(), String>, -{ - let runner = RealCommandRunner; - let setup_runner = RealPluginSetupRunner; - let hosts = select_hosts(host, mode, options, &runner)?; - if hosts.is_empty() { - return Err(CliError::Install(match host { - PluginHost::All => match mode { - HostSelectionMode::Install => { - "no supported Claude Code or Codex host CLI was detected".into() - } - HostSelectionMode::InstalledState => { - "no installed Claude Code or Codex plugin state was found".into() - } - }, - _ => "no supported plugin host selected".into(), - })); - } - for host in hosts { - action(host, options, &runner, &setup_runner).map_err(CliError::Install)?; - } - Ok(ExitCode::SUCCESS) -} - -fn doctor_json(host: PluginHost, options: &PluginInstallOptions) -> Result { - let runner = RealCommandRunner; - let setup_runner = RealPluginSetupRunner; - let hosts = select_hosts(host, HostSelectionMode::InstalledState, options, &runner)?; - if hosts.is_empty() { - return Err(CliError::Install(match host { - PluginHost::All => "no installed Claude Code or Codex plugin state was found".into(), - _ => "no supported plugin host selected".into(), - })); - } - let reports = hosts - .into_iter() - .map(|host| doctor_host_json_value(host, options, &runner, &setup_runner)) - .collect::, _>>() - .map_err(CliError::Install)?; - let ready = reports - .iter() - .all(|report| report.get("ok").and_then(Value::as_bool) == Some(true)); - if matches!(host, PluginHost::All) { - print_json(&json!({ - "schema_version": 1, - "plugins": reports - })) - } else { - print_json(&with_schema( - reports.into_iter().next().expect("hosts is not empty"), - )) - } - .map_err(CliError::Install)?; - Ok(if ready { - ExitCode::SUCCESS - } else { - ExitCode::FAILURE - }) -} - -fn select_hosts( - host: PluginHost, - mode: HostSelectionMode, - options: &PluginInstallOptions, - runner: &dyn CommandRunner, -) -> Result, CliError> { - if host != PluginHost::All { - return Ok(vec![host]); - } - let mut hosts = Vec::new(); - for candidate in [PluginHost::Codex, PluginHost::ClaudeCode] { - let selected = match mode { - HostSelectionMode::Install => runner - .resolve_executable(host_cli(candidate)) - .map_err(CliError::Install)? - .is_some(), - HostSelectionMode::InstalledState => { - state_path(candidate, &options.install_dir).exists() - } - }; - if selected { - hosts.push(candidate); - } - } - Ok(hosts) -} - -fn install_host( - host: PluginHost, - options: &PluginInstallOptions, - runner: &dyn CommandRunner, - setup_runner: &dyn PluginSetupRunner, -) -> Result<(), String> { - let relay = require_relay(options, runner)?; - validate_relay_plugin_shim(&relay, options, runner)?; - require_host_cli(host, options, runner)?; - let layout = PluginLayout::new(host, &options.install_dir); - if options.force { - force_cleanup_existing_install(host, &layout, options, runner, setup_runner)?; - } - write_plugin_marketplace(host, &layout, options)?; - if let Err(error) = write_state(&layout, options) { - if let Err(cleanup_error) = remove_path(&layout.marketplace_root, options) { - return Err(format!( - "{error}; additionally failed to remove generated marketplace {}: {cleanup_error}", - layout.marketplace_root.display() - )); - } - return Err(error); - } - let mut registration = HostRegistrationProgress::default(); - let mut setup_attempted = false; - let result = (|| { - run_host_marketplace_registration(host, &layout, options, runner)?; - registration.host_marketplace_added = true; - run_host_plugin_registration(host, options, runner)?; - registration.host_plugin_added = true; - setup_attempted = true; - run_plugin_setup(host, options, setup_runner)?; - mark_plugin_setup_installed(host, &layout, options)?; - if !options.skip_doctor { - run_plugin_doctor(host, options, setup_runner)?; - } - Ok(()) - })(); - if let Err(error) = result { - if let Err(rollback_error) = rollback_install( - host, - &layout, - registration, - setup_attempted, - options, - runner, - setup_runner, - ) { - return Err(format!( - "{error}; additionally failed to roll back install: {rollback_error}" - )); - } - return Err(error); - } - println!( - "installed {} plugin marketplace at {}", - host_label(host), - layout.marketplace_root.display() - ); - Ok(()) -} - -fn uninstall_host( - host: PluginHost, - options: &PluginInstallOptions, - runner: &dyn CommandRunner, - setup_runner: &dyn PluginSetupRunner, -) -> Result<(), String> { - uninstall_host_with_setup_override(host, options, runner, setup_runner, false) -} - -fn uninstall_host_with_setup_override( - host: PluginHost, - options: &PluginInstallOptions, - runner: &dyn CommandRunner, - setup_runner: &dyn PluginSetupRunner, - force_plugin_setup_uninstall: bool, -) -> Result<(), String> { - let state = read_state(host, &options.install_dir).unwrap_or_else(|| { - let layout = PluginLayout::new(host, &options.install_dir); - PluginState { - marketplace_root: layout.marketplace_root, - plugin_root: layout.plugin_root, - host_plugin_removed: false, - host_marketplace_removed: false, - plugin_setup_installed: true, - } - }); - if let Err(error) = require_relay(options, runner) - .and_then(|relay| validate_relay_plugin_shim(&relay, options, runner)) - { - eprintln!("warning: skipping nemo-relay validation during uninstall: {error}"); - } - let mut state = state; - if force_plugin_setup_uninstall && !state.plugin_setup_installed { - state.plugin_setup_installed = true; - write_state_for_host(host, &state, &options.install_dir, options)?; - } - run_host_unregistration(host, &mut state, &options.install_dir, options, runner)?; - if force_plugin_setup_uninstall || state.plugin_setup_installed { - run_plugin_uninstall(host, options, setup_runner)?; - state.plugin_setup_installed = false; - write_state_for_host(host, &state, &options.install_dir, options)?; - } - remove_path(&state.marketplace_root, options)?; - remove_path(&state_path(host, &options.install_dir), options)?; - println!("uninstalled {} plugin", host_label(host)); - Ok(()) -} - -fn doctor_host( - host: PluginHost, - options: &PluginInstallOptions, - runner: &dyn CommandRunner, - setup_runner: &dyn PluginSetupRunner, -) -> Result<(), String> { - let readiness = collect_host_plugin_readiness(host, options, runner, setup_runner); - println!("host: {}", readiness.host); - println!("state: {}", readiness.state_path.display()); - if let Some(path) = &readiness.marketplace { - println!("marketplace: {}", path.display()); - } - if let Some(path) = &readiness.plugin { - println!("plugin: {}", path.display()); - } - for check in &readiness.checks { - let marker = if check.ok { "ok" } else { "failed" }; - println!("{}: {marker} ({})", check.name, check.details); - } - readiness.ok().then_some(()).ok_or_else(|| { - format!( - "{} plugin doctor checks failed; run `nemo-relay install {} --force` to repair the installation", - host_label(host), - host_arg(host) - ) - }) -} - -fn doctor_host_json_value( - host: PluginHost, - options: &PluginInstallOptions, - runner: &dyn CommandRunner, - setup_runner: &dyn PluginSetupRunner, -) -> Result { - let readiness = collect_host_plugin_readiness(host, options, runner, setup_runner); - let host_registration_ok = readiness.host_plugin_registered == Some(true) - && readiness.host_marketplace_registered == Some(true); - Ok(json!({ - "ok": readiness.ok(), - "host": readiness.host, - "remediation": readiness.remediation, - "nemo_relay": readiness.relay, - "marketplace": readiness.marketplace, - "plugin": readiness.plugin, - "host_registration": { - "ok": host_registration_ok, - "host_plugin_registered": readiness.host_plugin_registered, - "host_marketplace_registered": readiness.host_marketplace_registered - }, - "checks": readiness.plugin_setup, - "state_path": readiness.state_path, - "readiness_checks": readiness.checks - })) -} - -fn collect_host_plugin_readiness( - host: PluginHost, - options: &PluginInstallOptions, - runner: &dyn CommandRunner, - setup_runner: &dyn PluginSetupRunner, -) -> HostPluginReadiness { - let state_path = state_path(host, &options.install_dir); - let state = read_state(host, &options.install_dir); - let layout = PluginLayout::new(host, &options.install_dir); - let marketplace = state - .as_ref() - .map(|state| state.marketplace_root.clone()) - .or_else(|| state_path.exists().then(|| layout.marketplace_root.clone())); - let plugin = state - .as_ref() - .map(|state| state.plugin_root.clone()) - .or_else(|| state_path.exists().then(|| layout.plugin_root.clone())); - let mut readiness = HostPluginReadiness { - host: host_arg(host).to_string(), - remediation: format!("nemo-relay install {} --force", host_arg(host)), - state_path: state_path.clone(), - marketplace, - plugin, - checks: Vec::new(), - relay: None, - host_plugin_registered: None, - host_marketplace_registered: None, - plugin_setup: None, - }; - - readiness.push( - "Install state", - state - .as_ref() - .map(|_| format!("valid state at {}", state_path.display())) - .ok_or_else(|| format!("missing or invalid state at {}", state_path.display())), - ); - if let Some(marketplace) = readiness.marketplace.as_ref() { - let manifest = marketplace_manifest_path(host, marketplace); - readiness.push( - "Generated marketplace", - generated_manifest_check(&manifest, &marketplace_manifest(host), "marketplace"), - ); - } - if let Some(plugin) = readiness.plugin.as_ref() { - let manifest = plugin_manifest_path(host, plugin); - readiness.push( - "Generated plugin", - generated_manifest_check(&manifest, &plugin_manifest(host), "plugin"), - ); - } - - let relay = require_relay(options, runner); - readiness.push( - "Relay binary", - relay - .as_ref() - .map(|path| format!("found at {}", path.display())) - .map_err(Clone::clone), - ); - if let Ok(relay) = relay { - readiness.relay = Some(relay.clone()); - readiness.push( - "Relay hook support", - validate_relay_plugin_shim(&relay, options, runner) - .map(|_| "plugin-shim hook is supported".into()), - ); - } - - let host_cli_check = require_host_cli(host, options, runner); - readiness.push( - "Host CLI", - host_cli_check - .as_ref() - .map(|_| format!("{} is available", host_cli(host))) - .map_err(Clone::clone), - ); - if host_cli_check.is_ok() { - match host_registration_report(host, options, runner) { - Ok(report) => { - readiness.host_plugin_registered = Some(report.host_plugin_registered); - readiness.host_marketplace_registered = Some(report.host_marketplace_registered); - readiness.push( - "Host registration", - report - .ok() - .then_some("plugin and marketplace registered".into()) - .ok_or_else(|| "plugin or marketplace registration is incomplete".into()), - ); - readiness.push( - "Host plugin registration", - report - .host_plugin_registered - .then_some("registered".into()) - .ok_or_else(|| "nemo-relay host plugin is not registered".into()), - ); - readiness.push( - "Host marketplace registration", - report - .host_marketplace_registered - .then_some("registered".into()) - .ok_or_else(|| "nemo-relay marketplace is not registered".into()), - ); - } - Err(error) => readiness.push("Host registration", Err(error)), - } - } - - match run_plugin_doctor_json(host, setup_runner) { - Ok(plugin_report) => { - append_plugin_setup_checks(&mut readiness, &plugin_report); - readiness.plugin_setup = Some(plugin_report); - } - Err(error) => readiness.push("Host setup", Err(error)), - } - readiness -} - -fn append_plugin_setup_checks(readiness: &mut HostPluginReadiness, report: &Value) { - if let Some(health) = report.get("sidecar_health").and_then(Value::as_str) { - readiness.push("Sidecar health", Ok(health.to_string())); - } - if let Some(checks) = report.get("checks").and_then(Value::as_object) { - for (name, value) in checks { - if name == "sidecar_running" { - continue; - } - let details = name.replace('_', " "); - readiness.push( - details, - value - .as_bool() - .filter(|ok| *ok) - .map(|_| "configured".into()) - .ok_or_else(|| "not configured".into()), - ); - } - } -} - -fn without_version(mut value: Value) -> Value { - if let Some(object) = value.as_object_mut() { - object.remove("version"); - } - value -} - -fn generated_manifest_check(path: &Path, expected: &Value, label: &str) -> Result { - let raw = std::fs::read_to_string(path).map_err(|error| { - format!( - "missing or unreadable {label} manifest {}: {error}", - path.display() - ) - })?; - let actual = serde_json::from_str::(&raw) - .map_err(|error| format!("invalid {label} manifest {}: {error}", path.display()))?; - if without_version(actual) == without_version(expected.clone()) { - Ok(format!("valid at {}", path.display())) - } else { - Err(format!( - "unexpected {label} manifest contents at {}", - path.display() - )) - } -} - -fn marketplace_manifest_path(host: PluginHost, root: &Path) -> PathBuf { - match host { - PluginHost::Codex => root - .join(".agents") - .join("plugins") - .join("marketplace.json"), - PluginHost::ClaudeCode => root.join(".claude-plugin").join("marketplace.json"), - PluginHost::All => unreachable!("all is expanded before layout resolution"), - } -} - -fn plugin_manifest_path(host: PluginHost, root: &Path) -> PathBuf { - match host { - PluginHost::Codex => root.join(".codex-plugin").join("plugin.json"), - PluginHost::ClaudeCode => root.join(".claude-plugin").join("plugin.json"), - PluginHost::All => unreachable!("all is expanded before layout resolution"), - } -} - -fn force_cleanup_existing_install( - host: PluginHost, - layout: &PluginLayout, - options: &PluginInstallOptions, - runner: &dyn CommandRunner, - setup_runner: &dyn PluginSetupRunner, -) -> Result<(), String> { - if layout.state_path.exists() { - uninstall_host(host, options, runner, setup_runner)?; - } else { - let mut state = PluginState { - marketplace_root: layout.marketplace_root.clone(), - plugin_root: layout.plugin_root.clone(), - host_plugin_removed: false, - host_marketplace_removed: false, - plugin_setup_installed: false, - }; - run_host_unregistration(host, &mut state, &options.install_dir, options, runner)?; - remove_path(&layout.marketplace_root, options)?; - remove_path(&layout.state_path, options)?; - } - Ok(()) -} - -fn rollback_install( - host: PluginHost, - layout: &PluginLayout, - registration: HostRegistrationProgress, - setup_attempted: bool, - options: &PluginInstallOptions, - runner: &dyn CommandRunner, - setup_runner: &dyn PluginSetupRunner, -) -> Result<(), String> { - if setup_attempted { - return uninstall_host_with_setup_override(host, options, runner, setup_runner, true); - } - let mut state = read_state(host, &options.install_dir).unwrap_or_else(|| PluginState { - marketplace_root: layout.marketplace_root.clone(), - plugin_root: layout.plugin_root.clone(), - host_plugin_removed: false, - host_marketplace_removed: false, - plugin_setup_installed: false, - }); - if registration.any_added() { - state.host_plugin_removed |= !registration.host_plugin_added; - state.host_marketplace_removed |= !registration.host_marketplace_added; - write_state_for_host(host, &state, &options.install_dir, options)?; - run_host_unregistration(host, &mut state, &options.install_dir, options, runner)?; - } - remove_path(&layout.marketplace_root, options)?; - remove_path(&layout.state_path, options) -} - -fn run_host_unregistration( - host: PluginHost, - state: &mut PluginState, - install_dir: &Path, - options: &PluginInstallOptions, - runner: &dyn CommandRunner, -) -> Result<(), String> { - if !state.host_plugin_removed { - require_host_cli(host, options, runner)?; - run_host_plugin_removal(host, options, runner)?; - state.host_plugin_removed = true; - write_state_for_host(host, state, install_dir, options)?; - } - if !state.host_marketplace_removed { - require_host_cli(host, options, runner)?; - run_host_marketplace_removal(host, options, runner)?; - state.host_marketplace_removed = true; - write_state_for_host(host, state, install_dir, options)?; - } - Ok(()) -} - -fn host_arg(host: PluginHost) -> &'static str { - match host { - PluginHost::Codex => "codex", - PluginHost::ClaudeCode => "claude-code", - PluginHost::All => "all", - } -} - -fn host_label(host: PluginHost) -> &'static str { - match host { - PluginHost::Codex => "Codex", - PluginHost::ClaudeCode => "Claude Code", - PluginHost::All => "all", - } -} - -fn host_cli(host: PluginHost) -> &'static str { - match host { - PluginHost::Codex => "codex", - PluginHost::ClaudeCode => "claude", - PluginHost::All => unreachable!("all is expanded before host CLI resolution"), - } -} - -fn print_json(value: &Value) -> Result<(), String> { - let rendered = serde_json::to_string_pretty(value).map_err(|error| error.to_string())?; - println!("{rendered}"); - Ok(()) -} - -fn with_schema(mut value: Value) -> Value { - if let Some(object) = value.as_object_mut() { - object.insert("schema_version".into(), json!(1)); - } - value -} - -#[cfg(test)] -use marketplace::*; -#[cfg(test)] -use setup::setup_action_description; -#[cfg(test)] -use state::*; - -#[cfg(test)] -#[path = "../../tests/coverage/plugin_install_tests.rs"] -mod tests; diff --git a/crates/cli/src/plugin_install/setup.rs b/crates/cli/src/plugin_install/setup.rs deleted file mode 100644 index b52d36ff0..000000000 --- a/crates/cli/src/plugin_install/setup.rs +++ /dev/null @@ -1,122 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -//! Plugin-shim setup, restore, and doctor delegation. - -use crate::config::{CodingAgent, PluginHost}; -use crate::plugin_shim; -use serde_json::Value; - -use super::DEFAULT_GATEWAY_URL; -use super::state::PluginInstallOptions; - -pub(super) fn run_plugin_setup( - host: PluginHost, - options: &PluginInstallOptions, - setup_runner: &dyn PluginSetupRunner, -) -> Result<(), String> { - if options.dry_run { - println!("{}", setup_action_description(host, "configure")); - return Ok(()); - } - setup_runner.setup(host, DEFAULT_GATEWAY_URL) -} - -pub(super) fn run_plugin_uninstall( - host: PluginHost, - options: &PluginInstallOptions, - setup_runner: &dyn PluginSetupRunner, -) -> Result<(), String> { - if options.dry_run { - println!("{}", setup_action_description(host, "restore")); - return Ok(()); - } - setup_runner.uninstall(host, DEFAULT_GATEWAY_URL) -} - -pub(super) fn run_plugin_doctor( - host: PluginHost, - options: &PluginInstallOptions, - setup_runner: &dyn PluginSetupRunner, -) -> Result<(), String> { - if options.dry_run { - println!("{}", setup_action_description(host, "doctor")); - return Ok(()); - } - setup_runner.doctor(host, DEFAULT_GATEWAY_URL) -} - -pub(super) fn run_plugin_doctor_json( - host: PluginHost, - setup_runner: &dyn PluginSetupRunner, -) -> Result { - setup_runner.doctor_json(host, DEFAULT_GATEWAY_URL) -} - -pub(super) fn setup_action_description(host: PluginHost, action: &str) -> String { - match (host, action) { - (PluginHost::Codex, "configure") => { - "configure Codex provider and hook-supervised lazy startup".into() - } - (PluginHost::Codex, "restore") => { - "restore Codex provider and generated hook configuration".into() - } - (PluginHost::Codex, "doctor") => "check Codex provider and generated hooks".into(), - (PluginHost::ClaudeCode, "configure") => { - "enable Claude Code provider routing through NeMo Relay".into() - } - (PluginHost::ClaudeCode, "restore") => { - "restore Claude Code provider routing from NeMo Relay backup".into() - } - (PluginHost::ClaudeCode, "doctor") => "check Claude Code provider routing".into(), - (PluginHost::All, _) => unreachable!("all is expanded before plugin setup"), - (_, _) => unreachable!("unsupported setup action"), - } -} - -pub(super) trait PluginSetupRunner { - fn setup(&self, host: PluginHost, gateway_url: &str) -> Result<(), String>; - fn uninstall(&self, host: PluginHost, gateway_url: &str) -> Result<(), String>; - fn doctor(&self, host: PluginHost, gateway_url: &str) -> Result<(), String>; - fn doctor_json(&self, host: PluginHost, gateway_url: &str) -> Result; -} - -pub(super) struct RealPluginSetupRunner; - -impl PluginSetupRunner for RealPluginSetupRunner { - fn setup(&self, host: PluginHost, gateway_url: &str) -> Result<(), String> { - match host { - PluginHost::Codex => plugin_shim::install_codex_plugin(gateway_url), - PluginHost::ClaudeCode => plugin_shim::enable_claude_provider(gateway_url), - PluginHost::All => unreachable!("all is expanded before plugin setup"), - } - } - - fn uninstall(&self, host: PluginHost, gateway_url: &str) -> Result<(), String> { - match host { - PluginHost::Codex => plugin_shim::uninstall_codex_plugin(gateway_url), - PluginHost::ClaudeCode => plugin_shim::restore_claude_provider(gateway_url), - PluginHost::All => unreachable!("all is expanded before plugin uninstall"), - } - } - - fn doctor(&self, host: PluginHost, gateway_url: &str) -> Result<(), String> { - match host { - PluginHost::Codex => plugin_shim::doctor_plugin(CodingAgent::Codex, gateway_url), - PluginHost::ClaudeCode => { - plugin_shim::doctor_plugin(CodingAgent::ClaudeCode, gateway_url) - } - PluginHost::All => unreachable!("all is expanded before plugin doctor"), - } - } - - fn doctor_json(&self, host: PluginHost, gateway_url: &str) -> Result { - match host { - PluginHost::Codex => plugin_shim::doctor_plugin_json(CodingAgent::Codex, gateway_url), - PluginHost::ClaudeCode => { - plugin_shim::doctor_plugin_json(CodingAgent::ClaudeCode, gateway_url) - } - PluginHost::All => unreachable!("all is expanded before plugin doctor"), - } - } -} diff --git a/crates/cli/src/plugin_shim/claude.rs b/crates/cli/src/plugin_shim/claude.rs deleted file mode 100644 index 24751fadb..000000000 --- a/crates/cli/src/plugin_shim/claude.rs +++ /dev/null @@ -1,179 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -//! Claude Code-specific provider routing setup. - -use std::fs; -use std::path::{Path, PathBuf}; -use std::process::ExitCode; - -use serde_json::{Value, json}; - -use super::command::PluginShimProviderAction; -use super::shared::{ - backup, backup_path, home_dir, read_json_object, remove_backup, restore_file_snapshot, - snapshot_optional_file, write_json, -}; - -pub(super) fn claude_provider( - action: PluginShimProviderAction, - gateway_url: &str, -) -> Result { - match action { - PluginShimProviderAction::Enable => { - let path = claude_settings_path()?; - let mut settings = read_json_object(&path)?; - if settings.get("env").is_some_and(|env| !env.is_object()) { - return Err(format!("{} has a non-object env field", path.display())); - } - let backup_snapshot = snapshot_optional_file(&backup_path(&path))?; - let managed_provider = - json_env_string(&settings, "ANTHROPIC_BASE_URL") == Some(gateway_url); - if !managed_provider && let Err(error) = backup_claude_settings(&path, true) { - restore_file_snapshot(&backup_snapshot)?; - return Err(error); - } - let env = settings - .as_object_mut() - .expect("read_json_object returns an object") - .entry("env") - .or_insert_with(|| json!({})); - let env = env.as_object_mut().expect("env was validated as an object"); - env.insert("ANTHROPIC_BASE_URL".into(), json!(gateway_url)); - if let Err(error) = write_json(&path, &settings) { - restore_file_snapshot(&backup_snapshot)?; - return Err(error); - } - println!("set ANTHROPIC_BASE_URL={gateway_url} in {}", path.display()); - Ok(ExitCode::SUCCESS) - } - PluginShimProviderAction::Restore => { - let path = claude_settings_path()?; - let backup = backup_path(&path); - if !backup.exists() { - println!( - "no backup found at {}; no managed Claude provider routing to restore", - backup.display() - ); - return Ok(ExitCode::SUCCESS); - } - let mut settings = read_json_object(&path)?; - if json_env_string(&settings, "ANTHROPIC_BASE_URL") == Some(gateway_url) { - let backup_settings = read_json_object(&backup)?; - restore_json_env_value(&mut settings, &backup_settings, "ANTHROPIC_BASE_URL")?; - write_json(&path, &settings)?; - remove_backup(&path)?; - println!( - "restored managed ANTHROPIC_BASE_URL in {} from {}", - path.display(), - backup.display() - ); - } else { - println!( - "current Claude provider routing is not managed by Relay; left {} unchanged", - path.display() - ); - } - Ok(ExitCode::SUCCESS) - } - PluginShimProviderAction::Status => { - println!( - "{}", - claude_settings_base_url().unwrap_or_else(|| { - "ANTHROPIC_BASE_URL is not configured in Claude settings".into() - }) - ); - Ok(ExitCode::SUCCESS) - } - } -} - -pub(super) fn json_env_string<'a>(value: &'a Value, key: &str) -> Option<&'a str> { - value - .get("env") - .and_then(Value::as_object) - .and_then(|env| env.get(key)) - .and_then(Value::as_str) -} - -pub(super) fn remove_json_env_string(value: &mut Value, key: &str) -> Result { - let Some(object) = value.as_object_mut() else { - return Err("Claude settings must be a JSON object".into()); - }; - let Some(env) = object.get_mut("env") else { - return Ok(false); - }; - let Some(env) = env.as_object_mut() else { - return Err("Claude settings env field must be a JSON object".into()); - }; - let removed = env.remove(key).is_some(); - if env.is_empty() { - object.remove("env"); - } - Ok(removed) -} - -pub(super) fn restore_json_env_value( - value: &mut Value, - backup: &Value, - key: &str, -) -> Result<(), String> { - let backup_value = backup - .get("env") - .and_then(Value::as_object) - .and_then(|env| env.get(key)) - .cloned(); - if let Some(backup_value) = backup_value { - let Some(object) = value.as_object_mut() else { - return Err("Claude settings must be a JSON object".into()); - }; - let env = object.entry("env").or_insert_with(|| json!({})); - let Some(env) = env.as_object_mut() else { - return Err("Claude settings env field must be a JSON object".into()); - }; - env.insert(key.into(), backup_value); - } else { - remove_json_env_string(value, key)?; - } - Ok(()) -} - -pub(super) fn backup_claude_settings(path: &Path, replace_existing: bool) -> Result<(), String> { - let backup_file = backup_path(path); - if backup_file.exists() && !replace_existing { - return Ok(()); - } - if path.exists() { - if replace_existing && backup_file.exists() { - fs::remove_file(&backup_file).map_err(|error| { - format!( - "failed to remove stale backup {}: {error}", - backup_file.display() - ) - })?; - } - backup(path) - } else { - if let Some(parent) = backup_file.parent() { - fs::create_dir_all(parent) - .map_err(|error| format!("failed to create {}: {error}", parent.display()))?; - } - fs::write(&backup_file, b"{}\n") - .map_err(|error| format!("failed to write {}: {error}", backup_file.display())) - } -} - -pub(super) fn claude_settings_path() -> Result { - Ok(home_dir()?.join(".claude").join("settings.json")) -} - -pub(super) fn claude_settings_base_url() -> Option { - let path = claude_settings_path().ok()?; - let value = read_json_object(&path).ok()?; - value - .get("env") - .and_then(Value::as_object) - .and_then(|env| env.get("ANTHROPIC_BASE_URL")) - .and_then(Value::as_str) - .map(ToOwned::to_owned) -} diff --git a/crates/cli/src/plugin_shim/codex.rs b/crates/cli/src/plugin_shim/codex.rs deleted file mode 100644 index 85933d50f..000000000 --- a/crates/cli/src/plugin_shim/codex.rs +++ /dev/null @@ -1,597 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -//! Codex-specific plugin setup, provider routing, and hook configuration. - -use std::fs; -use std::path::Path; -use std::process::ExitCode; - -use serde_json::{Value, json}; -use toml_edit::{DocumentMut, Item, Table, value}; - -use crate::config::CodingAgent; -use crate::installer::{generated_hooks, merge_hooks}; - -use super::shared::{ - FileSnapshot, atomic_write, backup, backup_path, current_exe, ensure_table, home_dir, - read_json_object, remove_backup, restore_file_snapshot, snapshot_optional_file, write_json, -}; - -pub(super) fn install_codex(gateway_url: &str) -> Result { - let codex_dir = home_dir()?.join(".codex"); - fs::create_dir_all(&codex_dir) - .map_err(|error| format!("failed to create {}: {error}", codex_dir.display()))?; - let config_path = codex_dir.join("config.toml"); - let hooks_path = codex_dir.join("hooks.json"); - prepare_codex_config(&config_path)?; - let hooks_snapshot = snapshot_optional_file(&hooks_path)?; - let hooks_backup_snapshot = snapshot_optional_file(&backup_path(&hooks_path))?; - if let Err(error) = install_codex_hooks(&hooks_path, gateway_url) { - if let Err(rollback_error) = - restore_codex_hooks_snapshot(&hooks_snapshot, &hooks_backup_snapshot) - { - return Err(format!( - "{error}; additionally failed to roll back Codex hooks at {}: {rollback_error}", - hooks_path.display() - )); - } - return Err(error); - } - if let Err(error) = install_codex_config(&config_path, gateway_url) { - if let Err(rollback_error) = - restore_codex_hooks_snapshot(&hooks_snapshot, &hooks_backup_snapshot) - { - return Err(format!( - "{error}; additionally failed to roll back Codex hooks at {}: {rollback_error}", - hooks_path.display() - )); - } - return Err(error); - } - println!("updated {}", config_path.display()); - println!("updated {}", hooks_path.display()); - println!("Codex Relay sidecar startup is hook-supervised; no daemon was installed."); - Ok(ExitCode::SUCCESS) -} - -pub(super) fn uninstall_codex(installed_gateway_url: &str) -> Result { - let codex_dir = home_dir()?.join(".codex"); - let config_path = codex_dir.join("config.toml"); - let hooks_path = codex_dir.join("hooks.json"); - let hook_gateway_url = - codex_provider_gateway_url(&config_path).unwrap_or_else(|| installed_gateway_url.into()); - let hooks_snapshot = snapshot_optional_file(&hooks_path)?; - let hooks_backup_snapshot = snapshot_optional_file(&backup_path(&hooks_path))?; - let has_remaining_hooks = uninstall_codex_hooks(&hooks_path, &hook_gateway_url)?; - if let Err(error) = - uninstall_codex_config(&config_path, installed_gateway_url, has_remaining_hooks) - { - if let Err(rollback_error) = - restore_codex_hooks_snapshot(&hooks_snapshot, &hooks_backup_snapshot) - { - return Err(format!( - "{error}; additionally failed to roll back Codex hooks at {}: {rollback_error}", - hooks_path.display() - )); - } - return Err(error); - } - println!("updated {}", config_path.display()); - println!("updated {}", hooks_path.display()); - println!("removed Codex Relay hook-supervised sidecar setup."); - Ok(ExitCode::SUCCESS) -} - -pub(super) fn prepare_codex_config(path: &Path) -> Result<(), String> { - let raw = read_optional_text(path)?; - raw.parse::() - .map(|_| ()) - .map_err(|error| format!("invalid TOML in {}: {error}", path.display())) -} - -pub(super) fn install_codex_config(path: &Path, gateway_url: &str) -> Result<(), String> { - let raw = read_optional_text(path)?; - let mut doc = raw - .parse::() - .map_err(|error| format!("invalid TOML in {}: {error}", path.display()))?; - let backup_snapshot = snapshot_optional_file(&backup_path(path))?; - if !codex_config_doc_has_managed_install(&doc, gateway_url) { - backup(path)?; - } - doc["model_provider"] = value("nemo-relay-openai"); - ensure_table(&mut doc, "features")["hooks"] = value(true); - - let providers = ensure_table(&mut doc, "model_providers"); - let mut provider = Table::new(); - provider["name"] = value("NeMo Relay"); - provider["base_url"] = value(gateway_url); - provider["wire_api"] = value("responses"); - provider["requires_openai_auth"] = value(true); - provider["supports_websockets"] = value(false); - providers["nemo-relay-openai"] = Item::Table(provider); - - if let Err(error) = atomic_write(path, doc.to_string().as_bytes()) { - restore_file_snapshot(&backup_snapshot)?; - return Err(error); - } - Ok(()) -} - -pub(super) fn read_optional_text(path: &Path) -> Result { - match fs::read_to_string(path) { - Ok(raw) => Ok(raw), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(String::new()), - Err(error) => Err(format!("failed to read {}: {error}", path.display())), - } -} - -pub(super) fn uninstall_codex_config( - path: &Path, - gateway_url: &str, - preserve_hooks: bool, -) -> Result<(), String> { - if !path.exists() { - return Ok(()); - } - let raw = fs::read_to_string(path) - .map_err(|error| format!("failed to read {}: {error}", path.display()))?; - let mut doc = raw - .parse::() - .map_err(|error| format!("invalid TOML in {}: {error}", path.display()))?; - let backup_doc = read_codex_backup_doc(path)?; - let provider_is_managed = codex_provider_item_is_managed(&doc, gateway_url); - match backup_doc.as_ref() { - Some(backup_doc) => { - restore_codex_config_from_backup( - &mut doc, - backup_doc, - provider_is_managed, - preserve_hooks, - ); - } - None => remove_codex_config_without_backup(&mut doc, provider_is_managed, preserve_hooks), - } - - remove_empty_table(&mut doc, "model_providers"); - remove_empty_table(&mut doc, "features"); - atomic_write(path, doc.to_string().as_bytes())?; - remove_backup(path) -} - -fn read_codex_backup_doc(path: &Path) -> Result, String> { - let backup = backup_path(path); - if !backup.exists() { - return Ok(None); - } - let raw = fs::read_to_string(&backup) - .map_err(|error| format!("failed to read {}: {error}", backup.display()))?; - raw.parse::() - .map(Some) - .map_err(|error| format!("invalid TOML in {}: {error}", backup.display())) -} - -fn restore_codex_config_from_backup( - doc: &mut DocumentMut, - backup_doc: &DocumentMut, - provider_is_managed: bool, - preserve_hooks: bool, -) { - if provider_is_managed { - restore_top_level_item_if_str(doc, backup_doc, "model_provider", "nemo-relay-openai"); - restore_table_item(doc, backup_doc, "model_providers", "nemo-relay-openai"); - } - if !preserve_hooks || feature_hooks_enabled(doc) != Some(true) { - restore_table_item_if_bool(doc, backup_doc, "features", "hooks", true); - } -} - -fn remove_codex_config_without_backup( - doc: &mut DocumentMut, - provider_is_managed: bool, - preserve_hooks: bool, -) { - if !provider_is_managed { - return; - } - if top_level_item_is_str(doc, "model_provider", "nemo-relay-openai") { - doc.as_table_mut().remove("model_provider"); - } - if let Some(providers) = doc.get_mut("model_providers").and_then(Item::as_table_mut) { - providers.remove("nemo-relay-openai"); - } - if !preserve_hooks { - remove_table_item_if_bool(doc, "features", "hooks", true); - } -} - -pub(super) fn install_codex_hooks(path: &Path, gateway_url: &str) -> Result<(), String> { - let relay = current_exe()?; - let command = codex_hook_command(gateway_url); - let generated = generated_hooks(CodingAgent::Codex, &command); - let mut existing = if path.exists() { - let raw = fs::read_to_string(path) - .map_err(|error| format!("failed to read {}: {error}", path.display()))?; - let existing = serde_json::from_str::(&raw) - .map_err(|error| format!("invalid JSON in {}: {error}", path.display()))?; - if !hook_config_contains_generated_groups(&existing, &generated) { - backup(path)?; - } - existing - } else { - json!({}) - }; - remove_managed_codex_hook_groups(&mut existing, &relay, Some(gateway_url)); - let merged = merge_hooks(existing, generated).map_err(|error| error.to_string())?; - let bytes = serde_json::to_vec_pretty(&merged).map_err(|error| error.to_string())?; - let mut output = bytes; - output.push(b'\n'); - atomic_write(path, &output) -} - -pub(super) fn uninstall_codex_hooks(path: &Path, _gateway_url: &str) -> Result { - if !path.exists() { - return Ok(false); - } - let mut value = read_json_object(path)?; - let relay = current_exe()?; - remove_managed_codex_hook_groups(&mut value, &relay, None); - let has_remaining_hooks = hook_config_has_hook_groups(&value); - write_json(path, &value)?; - Ok(has_remaining_hooks) -} - -pub(super) fn remove_managed_codex_hook_groups( - value: &mut Value, - relay: &Path, - keep_gateway_url: Option<&str>, -) { - let Some(hooks) = value.get_mut("hooks").and_then(Value::as_object_mut) else { - return; - }; - let events: Vec = hooks.keys().cloned().collect(); - for event in events { - let should_remove_event = hooks - .get_mut(&event) - .and_then(Value::as_array_mut) - .map(|groups| { - groups.retain(|group| { - !managed_codex_hook_group_for_relay(group, relay, keep_gateway_url) - }); - groups.is_empty() - }) - .unwrap_or(false); - if should_remove_event { - hooks.remove(&event); - } - } -} - -pub(super) fn managed_codex_hook_group_for_relay( - group: &Value, - relay: &Path, - keep_gateway_url: Option<&str>, -) -> bool { - let Some(hooks) = group.get("hooks").and_then(Value::as_array) else { - return false; - }; - let [hook] = hooks.as_slice() else { - return false; - }; - if hook.get("type").and_then(Value::as_str) != Some("command") - || hook.get("timeout").and_then(Value::as_u64) != Some(30) - { - return false; - } - let Some(command) = hook.get("command").and_then(Value::as_str) else { - return false; - }; - if keep_gateway_url.is_some_and(|gateway_url| command == codex_hook_command(gateway_url)) { - return false; - } - command == legacy_codex_hook_command(relay) - || command == legacy_named_codex_hook_command() - || command.starts_with("nemo-relay plugin-shim hook codex --gateway-url ") - || command.starts_with(&format!( - "{} plugin-shim hook codex --gateway-url ", - shell_quote(relay) - )) -} - -pub(super) fn hook_config_contains_generated_groups(existing: &Value, generated: &Value) -> bool { - let Some(generated_hooks) = generated.get("hooks").and_then(Value::as_object) else { - return false; - }; - generated_hooks.iter().all(|(event, groups)| { - groups.as_array().is_some_and(|groups| { - groups - .iter() - .all(|group| generated_event_contains_group(existing, event, group)) - }) - }) -} - -pub(super) fn generated_event_contains_group(config: &Value, event: &str, group: &Value) -> bool { - config - .get("hooks") - .and_then(Value::as_object) - .and_then(|hooks| hooks.get(event)) - .and_then(Value::as_array) - .is_some_and(|groups| groups.iter().any(|existing| existing == group)) -} - -pub(super) fn hook_config_has_hook_groups(config: &Value) -> bool { - config - .get("hooks") - .and_then(Value::as_object) - .is_some_and(|hooks| { - hooks - .values() - .any(|groups| groups.as_array().is_some_and(|groups| !groups.is_empty())) - }) -} - -pub(super) fn codex_config_doc_has_managed_install(doc: &DocumentMut, gateway_url: &str) -> bool { - doc.get("model_provider") - .and_then(Item::as_value) - .and_then(|value| value.as_str()) - == Some("nemo-relay-openai") - && codex_provider_item_is_managed(doc, gateway_url) - && feature_hooks_enabled(doc) == Some(true) -} - -pub(super) fn codex_provider_gateway_url(path: &Path) -> Option { - let raw = fs::read_to_string(path).ok()?; - let doc = raw.parse::().ok()?; - doc.get("model_providers") - .and_then(Item::as_table) - .and_then(|providers| providers.get("nemo-relay-openai")) - .and_then(Item::as_table) - .and_then(|provider| provider.get("base_url")) - .and_then(Item::as_value) - .and_then(|value| value.as_str()) - .map(ToOwned::to_owned) -} - -pub(super) fn restore_top_level_item(doc: &mut DocumentMut, backup: &DocumentMut, key: &str) { - if let Some(item) = backup.as_table().get(key).cloned() { - doc.as_table_mut().insert(key, item); - } else { - doc.as_table_mut().remove(key); - } -} - -pub(super) fn restore_top_level_item_if_str( - doc: &mut DocumentMut, - backup: &DocumentMut, - key: &str, - expected: &str, -) { - if top_level_item_is_str(doc, key, expected) { - restore_top_level_item(doc, backup, key); - } -} - -fn top_level_item_is_str(doc: &DocumentMut, key: &str, expected: &str) -> bool { - doc.get(key) - .and_then(Item::as_value) - .and_then(|value| value.as_str()) - == Some(expected) -} - -pub(super) fn restore_table_item( - doc: &mut DocumentMut, - backup: &DocumentMut, - table: &str, - key: &str, -) { - if let Some(item) = backup - .get(table) - .and_then(Item::as_table) - .and_then(|table| table.get(key)) - .cloned() - { - ensure_table(doc, table).insert(key, item); - } else if let Some(table) = doc.get_mut(table).and_then(Item::as_table_mut) { - table.remove(key); - } -} - -pub(super) fn restore_table_item_if_bool( - doc: &mut DocumentMut, - backup: &DocumentMut, - table: &str, - key: &str, - expected: bool, -) { - let current = doc - .get(table) - .and_then(Item::as_table) - .and_then(|table| table.get(key)) - .and_then(Item::as_value) - .and_then(|value| value.as_bool()); - if current == Some(expected) { - restore_table_item(doc, backup, table, key); - } -} - -pub(super) fn codex_provider_item_is_managed(doc: &DocumentMut, gateway_url: &str) -> bool { - doc.get("model_providers") - .and_then(Item::as_table) - .and_then(|providers| providers.get("nemo-relay-openai")) - .and_then(Item::as_table) - .is_some_and(|provider| codex_provider_table_is_managed_for_gateway(provider, gateway_url)) -} - -pub(super) fn codex_provider_table_is_managed_for_gateway( - provider: &Table, - gateway_url: &str, -) -> bool { - provider - .get("name") - .and_then(Item::as_value) - .and_then(|value| value.as_str()) - == Some("NeMo Relay") - && provider - .get("base_url") - .and_then(Item::as_value) - .and_then(|value| value.as_str()) - == Some(gateway_url) - && provider - .get("wire_api") - .and_then(Item::as_value) - .and_then(|value| value.as_str()) - == Some("responses") - && provider - .get("requires_openai_auth") - .and_then(Item::as_value) - .and_then(|value| value.as_bool()) - == Some(true) - && provider - .get("supports_websockets") - .and_then(Item::as_value) - .and_then(|value| value.as_bool()) - == Some(false) -} - -pub(super) fn feature_hooks_enabled(doc: &DocumentMut) -> Option { - doc.get("features") - .and_then(Item::as_table) - .and_then(|table| table.get("hooks")) - .and_then(Item::as_value) - .and_then(|value| value.as_bool()) -} - -pub(super) fn remove_empty_table(doc: &mut DocumentMut, key: &str) { - let is_empty = doc - .get(key) - .and_then(Item::as_table) - .is_some_and(Table::is_empty); - if is_empty { - doc.as_table_mut().remove(key); - } -} - -pub(super) fn remove_table_item_if_bool( - doc: &mut DocumentMut, - table: &str, - key: &str, - expected: bool, -) { - let should_remove = doc - .get(table) - .and_then(Item::as_table) - .and_then(|table| table.get(key)) - .and_then(Item::as_value) - .and_then(|value| value.as_bool()) - == Some(expected); - if should_remove && let Some(table) = doc.get_mut(table).and_then(Item::as_table_mut) { - table.remove(key); - } -} - -pub(super) fn codex_provider_installed(gateway_url: &str) -> bool { - let Ok(path) = home_dir().map(|home| home.join(".codex").join("config.toml")) else { - return false; - }; - let Ok(raw) = fs::read_to_string(path) else { - return false; - }; - let Ok(doc) = raw.parse::() else { - return false; - }; - codex_config_doc_has_managed_install(&doc, gateway_url) -} - -pub(super) fn codex_hooks_installed(gateway_url: &str) -> Result { - let path = home_dir()?.join(".codex").join("hooks.json"); - let value = read_json_object(&path)?; - let generated = generated_hooks(CodingAgent::Codex, &codex_hook_command(gateway_url)); - Ok(hook_config_contains_generated_groups(&value, &generated)) -} - -pub(super) fn restore_codex_hooks_snapshot( - hooks: &FileSnapshot, - hooks_backup: &FileSnapshot, -) -> Result<(), String> { - restore_file_snapshot(hooks)?; - restore_file_snapshot(hooks_backup) -} - -pub(super) fn shell_quote(path: &Path) -> String { - shell_quote_for_platform(path, cfg!(windows)) -} - -pub(super) fn shell_quote_for_platform(path: &Path, windows: bool) -> String { - shell_quote_arg_for_platform(&path.display().to_string(), windows) -} - -pub(super) fn shell_quote_arg_for_platform(raw: &str, windows: bool) -> String { - if windows { - return cmd_quote_arg(raw); - } - posix_quote_arg(raw) -} - -pub(super) fn posix_quote_arg(raw: &str) -> String { - if raw.is_empty() { - "''".into() - } else if raw - .chars() - .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '/' | ':' | '.' | '_' | '-')) - { - raw.to_string() - } else { - format!("'{}'", raw.replace('\'', "'\\''")) - } -} - -pub(super) fn cmd_quote_arg(raw: &str) -> String { - if raw.chars().all(|ch| { - ch.is_ascii_alphanumeric() - || matches!(ch, '/' | '\\' | ':' | '.' | '_' | '-' | '=' | '@' | '+') - }) { - raw.to_string() - } else { - let mut escaped = String::new(); - for ch in raw.chars() { - match ch { - '%' => escaped.push_str("%%"), - '"' | '^' | '&' | '|' | '<' | '>' => { - escaped.push('^'); - escaped.push(ch); - } - _ => escaped.push(ch), - } - } - format!("\"{escaped}\"") - } -} - -pub(super) fn codex_hook_command(gateway_url: &str) -> String { - format!( - "nemo-relay plugin-shim hook codex --gateway-url {}", - shell_quote_arg_for_platform(gateway_url, cfg!(windows)) - ) -} - -#[cfg(test)] -pub(super) fn codex_hook_command_for_platform( - relay: &Path, - gateway_url: &str, - windows: bool, -) -> String { - format!( - "{} plugin-shim hook codex --gateway-url {}", - shell_quote_for_platform(relay, windows), - shell_quote_arg_for_platform(gateway_url, windows) - ) -} - -pub(super) fn legacy_codex_hook_command(relay: &Path) -> String { - format!("{} plugin-shim hook codex", shell_quote(relay)) -} - -pub(super) fn legacy_named_codex_hook_command() -> &'static str { - "nemo-relay plugin-shim hook codex" -} diff --git a/crates/cli/src/plugin_shim/command.rs b/crates/cli/src/plugin_shim/command.rs deleted file mode 100644 index c451e8899..000000000 --- a/crates/cli/src/plugin_shim/command.rs +++ /dev/null @@ -1,80 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -//! Hidden `plugin-shim` CLI surface used by installed hooks and installer orchestration. - -use clap::{Args, Subcommand, ValueEnum}; - -use crate::config::CodingAgent; - -#[derive(Debug, Clone, Args)] -pub(crate) struct PluginShimCommand { - #[command(subcommand)] - pub(crate) command: PluginShimSubcommand, -} - -#[derive(Debug, Clone, Subcommand)] -pub(crate) enum PluginShimSubcommand { - Serve(PluginShimServeCommand), - Hook(PluginShimHookCommand), - Install(PluginShimInstallCommand), - Uninstall(PluginShimUninstallCommand), - Provider(PluginShimProviderCommand), - Doctor(PluginShimDoctorCommand), -} - -#[derive(Debug, Clone, Args)] -pub(crate) struct PluginShimServeCommand { - #[arg(allow_hyphen_values = true, trailing_var_arg = true)] - pub(crate) args: Vec, -} - -#[derive(Debug, Clone, Args)] -pub(crate) struct PluginShimHookCommand { - #[arg(value_enum)] - pub(crate) agent: CodingAgent, - #[arg(long)] - pub(crate) gateway_url: Option, -} - -#[derive(Debug, Clone, Args)] -pub(crate) struct PluginShimInstallCommand { - #[arg(value_enum)] - pub(crate) agent: CodingAgent, - #[arg(long, default_value = "http://127.0.0.1:47632")] - pub(crate) gateway_url: String, -} - -#[derive(Debug, Clone, Args)] -pub(crate) struct PluginShimUninstallCommand { - #[arg(value_enum)] - pub(crate) agent: CodingAgent, - #[arg(long, default_value = "http://127.0.0.1:47632")] - pub(crate) gateway_url: String, -} - -#[derive(Debug, Clone, Args)] -pub(crate) struct PluginShimProviderCommand { - #[arg(value_enum)] - pub(crate) agent: CodingAgent, - #[arg(value_enum)] - pub(crate) action: PluginShimProviderAction, - #[arg(long, default_value = "http://127.0.0.1:47632")] - pub(crate) gateway_url: String, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, ValueEnum)] -#[value(rename_all = "kebab-case")] -pub(crate) enum PluginShimProviderAction { - Enable, - Restore, - Status, -} - -#[derive(Debug, Clone, Args)] -pub(crate) struct PluginShimDoctorCommand { - #[arg(value_enum)] - pub(crate) agent: CodingAgent, - #[arg(long, default_value = "http://127.0.0.1:47632")] - pub(crate) gateway_url: String, -} diff --git a/crates/cli/src/plugin_shim/mod.rs b/crates/cli/src/plugin_shim/mod.rs deleted file mode 100644 index 20a08c177..000000000 --- a/crates/cli/src/plugin_shim/mod.rs +++ /dev/null @@ -1,274 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -//! Platform-neutral launcher and hook shim for packaged coding-agent plugins. - -mod claude; -mod codex; -mod command; -mod shared; - -pub(crate) use command::PluginShimCommand; - -use std::env; -use std::io::{Read, Write}; -use std::process::{Command, ExitCode}; - -use serde_json::{Value, json}; - -use claude::{claude_provider, claude_settings_base_url}; -use codex::{codex_hooks_installed, codex_provider_installed, install_codex, uninstall_codex}; -use command::{ - PluginShimDoctorCommand, PluginShimInstallCommand, PluginShimProviderAction, - PluginShimProviderCommand, PluginShimSubcommand, PluginShimUninstallCommand, -}; -use shared::{ - ExecOrStatus, current_exe, fail_closed, gateway_url, healthz, plugin_idle_timeout, post_hook, - print_check, print_info, relay_binary, -}; - -use crate::config::CodingAgent; -use crate::error::CliError; - -pub(super) const DEFAULT_BIND: &str = "127.0.0.1:47632"; -pub(super) const DEFAULT_URL: &str = "http://127.0.0.1:47632"; -pub(super) const HEALTHZ_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(500); -pub(super) const STALE_LOCK_AFTER: std::time::Duration = std::time::Duration::from_secs(10); - -pub(crate) fn run(command: PluginShimCommand) -> Result { - match command.command { - PluginShimSubcommand::Serve(command) => serve(command.args), - PluginShimSubcommand::Hook(command) => hook(command.agent, command.gateway_url.as_deref()), - PluginShimSubcommand::Install(command) => install(command), - PluginShimSubcommand::Uninstall(command) => uninstall(command), - PluginShimSubcommand::Provider(command) => provider(command), - PluginShimSubcommand::Doctor(command) => doctor(command), - } - .map_err(CliError::Install) -} - -fn serve(args: Vec) -> Result { - let relay = relay_binary()?; - let bind = env::var("NEMO_RELAY_PLUGIN_BIND").unwrap_or_else(|_| DEFAULT_BIND.into()); - let mut command = Command::new(relay); - command.arg("--bind").arg(bind).args(args); - command.env("NEMO_RELAY_PLUGIN_IDLE_TIMEOUT_SECS", plugin_idle_timeout()); - command - .exec_or_status() - .map_err(|error| format!("failed to start nemo-relay sidecar: {error}")) -} - -fn hook(agent: CodingAgent, explicit_gateway_url: Option<&str>) -> Result { - let mut input = std::io::stdin(); - let mut output = std::io::stdout(); - hook_with_io( - agent, - explicit_gateway_url, - &mut input, - &mut output, - shared::ensure_sidecar, - post_hook, - fail_closed, - ) -} - -fn hook_with_io( - agent: CodingAgent, - explicit_gateway_url: Option<&str>, - input: &mut R, - output: &mut W, - mut ensure_sidecar: E, - mut post_hook: P, - fail_closed: F, -) -> Result -where - R: Read, - W: Write, - E: FnMut(CodingAgent, &str), - P: FnMut(CodingAgent, &str, &[u8]) -> Result, String>, - F: FnOnce() -> bool, -{ - let url = gateway_url(agent, explicit_gateway_url); - let mut payload = Vec::new(); - input - .read_to_end(&mut payload) - .map_err(|error| format!("failed to read hook payload: {error}"))?; - if payload.iter().all(u8::is_ascii_whitespace) { - payload = b"{}".to_vec(); - } - ensure_sidecar(agent, &url); - match post_hook(agent, &url, &payload) { - Ok(body) => { - if !body.is_empty() { - output - .write_all(&body) - .map_err(|error| format!("failed to write hook response: {error}"))?; - } - Ok(ExitCode::SUCCESS) - } - Err(error) if fail_closed() => Err(error), - Err(error) => { - eprintln!("{error}"); - Ok(ExitCode::SUCCESS) - } - } -} - -fn install(command: PluginShimInstallCommand) -> Result { - match command.agent { - CodingAgent::Codex => install_codex(&command.gateway_url), - other => Err(format!( - "plugin install supports codex, got {}", - other.as_arg() - )), - } -} - -fn uninstall(command: PluginShimUninstallCommand) -> Result { - match command.agent { - CodingAgent::Codex => uninstall_codex(&command.gateway_url), - other => Err(format!( - "plugin uninstall supports codex, got {}", - other.as_arg() - )), - } -} - -fn provider(command: PluginShimProviderCommand) -> Result { - match command.agent { - CodingAgent::ClaudeCode => claude_provider(command.action, &command.gateway_url), - other => Err(format!( - "plugin provider supports claude, got {}", - other.as_arg() - )), - } -} - -fn doctor(command: PluginShimDoctorCommand) -> Result { - Ok(if doctor_ok(command.agent, &command.gateway_url)? { - ExitCode::SUCCESS - } else { - ExitCode::FAILURE - }) -} - -pub(crate) fn install_codex_plugin(gateway_url: &str) -> Result<(), String> { - install_codex(gateway_url).map(|_| ()) -} - -pub(crate) fn uninstall_codex_plugin(gateway_url: &str) -> Result<(), String> { - uninstall_codex(gateway_url).map(|_| ()) -} - -pub(crate) fn enable_claude_provider(gateway_url: &str) -> Result<(), String> { - claude_provider(PluginShimProviderAction::Enable, gateway_url).map(|_| ()) -} - -pub(crate) fn restore_claude_provider(gateway_url: &str) -> Result<(), String> { - claude_provider(PluginShimProviderAction::Restore, gateway_url).map(|_| ()) -} - -pub(crate) fn doctor_plugin(agent: CodingAgent, gateway_url: &str) -> Result<(), String> { - if doctor_ok(agent, gateway_url)? { - Ok(()) - } else { - Err(format!("{} plugin doctor checks failed", agent.as_arg())) - } -} - -pub(crate) fn doctor_plugin_json(agent: CodingAgent, gateway_url: &str) -> Result { - let plugin_binary = current_exe().ok().is_some_and(|path| path.exists()); - let sidecar_running = healthz(gateway_url); - let (checks, ok) = match agent { - CodingAgent::ClaudeCode => { - let provider = claude_settings_base_url().as_deref() == Some(gateway_url); - ( - json!({ - "plugin_binary": plugin_binary, - "sidecar_running": sidecar_running, - "claude_provider_routing": provider - }), - plugin_binary && provider, - ) - } - CodingAgent::Codex => { - let provider = codex_provider_installed(gateway_url); - let hooks = codex_hooks_installed(gateway_url)?; - ( - json!({ - "plugin_binary": plugin_binary, - "sidecar_running": sidecar_running, - "codex_provider_alias": provider, - "codex_hooks": hooks - }), - plugin_binary && provider && hooks, - ) - } - other => { - return Err(format!( - "plugin doctor supports claude and codex, got {}", - other.as_arg() - )); - } - }; - Ok(json!({ - "ok": ok, - "sidecar_health": if sidecar_running { - "running" - } else { - "not_running_lazy_start" - }, - "checks": checks - })) -} - -fn doctor_ok(agent: CodingAgent, gateway_url: &str) -> Result { - let mut ok = true; - ok &= print_check( - "plugin binary", - current_exe().ok().is_some_and(|path| path.exists()), - ); - if healthz(gateway_url) { - print_info("sidecar health", "running"); - } else { - print_info( - "sidecar health", - "not running; hooks start it lazily on first use", - ); - } - match agent { - CodingAgent::ClaudeCode => { - ok &= print_check( - "claude provider routing", - claude_settings_base_url().as_deref() == Some(gateway_url), - ); - } - CodingAgent::Codex => { - ok &= print_check( - "codex provider alias", - codex_provider_installed(gateway_url), - ); - ok &= print_check("codex hooks", codex_hooks_installed(gateway_url)?); - } - other => { - return Err(format!( - "plugin doctor supports claude and codex, got {}", - other.as_arg() - )); - } - } - Ok(ok) -} - -#[cfg(test)] -use crate::installer::generated_hooks; -#[cfg(test)] -use claude::*; -#[cfg(test)] -use codex::*; -#[cfg(test)] -use shared::*; - -#[cfg(test)] -#[path = "../../tests/coverage/plugin_shim_tests.rs"] -mod tests; diff --git a/crates/cli/src/plugin_shim/shared.rs b/crates/cli/src/plugin_shim/shared.rs deleted file mode 100644 index 70cae8d49..000000000 --- a/crates/cli/src/plugin_shim/shared.rs +++ /dev/null @@ -1,616 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -//! Shared plugin-shim filesystem, sidecar, HTTP, and formatting helpers. - -use std::env; -use std::fs::{self, OpenOptions}; -use std::io::{Read, Write}; -use std::net::{TcpStream, ToSocketAddrs}; -use std::path::{Path, PathBuf}; -use std::process::{Command, ExitCode, Stdio}; -use std::thread; -use std::time::Duration; - -use reqwest::Url; -use serde_json::{Value, json}; -use toml_edit::{DocumentMut, Item, Table}; - -use crate::config::CodingAgent; - -use super::{DEFAULT_URL, HEALTHZ_TIMEOUT, STALE_LOCK_AFTER}; - -pub(super) fn ensure_sidecar(agent: CodingAgent, url: &str) { - if healthz(url) { - return; - } - let runtime = runtime_dir(); - let _ = fs::create_dir_all(&runtime); - let lock = runtime.join(format!("{}-sidecar.lock", sidecar_lock_name(url))); - let mut acquired = false; - for _ in 0..40 { - match fs::create_dir(&lock) { - Ok(()) => { - acquired = true; - break; - } - Err(_) if healthz(url) => return, - Err(_) if repair_stale_lock(&lock) => continue, - Err(_) => thread::sleep(Duration::from_millis(50)), - } - } - if !acquired { - eprintln!("nemo-relay sidecar lock timed out"); - return; - } - let result = start_sidecar(agent, url, &runtime); - let _ = fs::remove_dir(&lock); - if let Err(error) = result { - eprintln!("{error}"); - } -} - -pub(super) fn repair_stale_lock(lock: &Path) -> bool { - repair_stale_lock_after(lock, STALE_LOCK_AFTER) -} - -pub(super) fn repair_stale_lock_after(lock: &Path, stale_after: Duration) -> bool { - if !lock.exists() || !lock_is_old(lock, stale_after) { - return false; - } - match fs::remove_dir_all(lock) { - Ok(()) => return true, - Err(error) => eprintln!("failed to repair stale nemo-relay sidecar lock: {error}"), - } - false -} - -pub(super) fn lock_is_old(lock: &Path, stale_after: Duration) -> bool { - lock.metadata() - .and_then(|metadata| metadata.modified()) - .ok() - .and_then(|modified| modified.elapsed().ok()) - .is_some_and(|elapsed| elapsed >= stale_after) -} - -pub(super) fn ensure_table<'a>(doc: &'a mut DocumentMut, name: &str) -> &'a mut Table { - if !doc.as_table().contains_key(name) || !doc[name].is_table() { - doc[name] = Item::Table(Table::new()); - } - doc[name].as_table_mut().expect("table was just inserted") -} - -pub(super) fn read_json_object(path: &Path) -> Result { - if !path.exists() { - return Ok(json!({})); - } - let raw = fs::read_to_string(path) - .map_err(|error| format!("failed to read {}: {error}", path.display()))?; - let value = serde_json::from_str::(&raw) - .map_err(|error| format!("invalid JSON in {}: {error}", path.display()))?; - if value.is_object() { - Ok(value) - } else { - Err(format!("{} must contain a JSON object", path.display())) - } -} - -pub(super) fn write_json(path: &Path, value: &Value) -> Result<(), String> { - let mut bytes = serde_json::to_vec_pretty(value).map_err(|error| error.to_string())?; - bytes.push(b'\n'); - atomic_write(path, &bytes) -} - -pub(super) fn atomic_write(path: &Path, bytes: &[u8]) -> Result<(), String> { - if let Some(parent) = path.parent() { - fs::create_dir_all(parent) - .map_err(|error| format!("failed to create {}: {error}", parent.display()))?; - } - let tmp = path.with_extension(format!( - "{}tmp", - path.extension() - .and_then(|value| value.to_str()) - .map(|value| format!("{value}.")) - .unwrap_or_default() - )); - fs::write(&tmp, bytes) - .map_err(|error| format!("failed to write {}: {error}", tmp.display()))?; - replace_file(&tmp, path) -} - -#[cfg(not(windows))] -pub(super) fn replace_file(tmp: &Path, path: &Path) -> Result<(), String> { - fs::rename(tmp, path).map_err(|error| format!("failed to replace {}: {error}", path.display())) -} - -#[cfg(windows)] -pub(super) fn replace_file(tmp: &Path, path: &Path) -> Result<(), String> { - if !path.exists() { - return fs::rename(tmp, path) - .map_err(|error| format!("failed to replace {}: {error}", path.display())); - } - - let backup = replace_backup_path(path); - match fs::remove_file(&backup) { - Ok(()) => {} - Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} - Err(error) => { - return Err(format!( - "failed to remove stale replacement backup {}: {error}", - backup.display() - )); - } - } - - match fs::rename(path, &backup) { - Ok(()) => {} - Err(error) if error.kind() == std::io::ErrorKind::NotFound => { - return fs::rename(tmp, path) - .map_err(|error| format!("failed to replace {}: {error}", path.display())); - } - Err(error) => { - return Err(format!( - "failed to prepare replacement for {}: {error}", - path.display() - )); - } - } - - match fs::rename(tmp, path) { - Ok(()) => { - let _ = fs::remove_file(&backup); - Ok(()) - } - Err(error) => match fs::rename(&backup, path) { - Ok(()) => Err(format!("failed to replace {}: {error}", path.display())), - Err(restore_error) => Err(format!( - "failed to replace {}: {error}; additionally failed to restore {}: {restore_error}", - path.display(), - backup.display() - )), - }, - } -} - -#[cfg(windows)] -pub(super) fn replace_backup_path(path: &Path) -> PathBuf { - let file_name = path - .file_name() - .and_then(|value| value.to_str()) - .unwrap_or("config"); - path.with_file_name(format!(".{file_name}.nemo-relay-replace.tmp")) -} - -pub(super) fn backup(path: &Path) -> Result<(), String> { - let backup = backup_path(path); - if backup.exists() { - return Ok(()); - } - if path.exists() { - fs::copy(path, &backup).map_err(|error| { - format!( - "failed to back up {} to {}: {error}", - path.display(), - backup.display() - ) - })?; - } - Ok(()) -} - -pub(super) fn remove_backup(path: &Path) -> Result<(), String> { - let backup = backup_path(path); - match fs::remove_file(&backup) { - Ok(()) => Ok(()), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), - Err(error) => Err(format!("failed to remove {}: {error}", backup.display())), - } -} - -pub(super) fn backup_path(path: &Path) -> PathBuf { - let mut extension = path - .extension() - .and_then(|value| value.to_str()) - .unwrap_or_default() - .to_string(); - if extension.is_empty() { - extension = "nemo-relay.bak".into(); - } else { - extension.push_str(".nemo-relay.bak"); - } - path.with_extension(extension) -} - -pub(super) fn home_dir() -> Result { - env::var_os("HOME") - .or_else(|| env::var_os("USERPROFILE")) - .map(PathBuf::from) - .ok_or_else(|| "cannot determine home directory (set HOME or USERPROFILE)".into()) -} - -pub(super) fn print_check(label: &str, ok: bool) -> bool { - println!("{} {label}", if ok { "ok" } else { "missing" }); - ok -} - -pub(super) fn print_info(label: &str, message: &str) { - println!("info {label}: {message}"); -} - -pub(super) struct FileSnapshot { - path: PathBuf, - bytes: Option>, -} - -pub(super) fn snapshot_optional_file(path: &Path) -> Result { - match fs::read(path) { - Ok(bytes) => Ok(FileSnapshot { - path: path.to_path_buf(), - bytes: Some(bytes), - }), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(FileSnapshot { - path: path.to_path_buf(), - bytes: None, - }), - Err(error) => Err(format!("failed to read {}: {error}", path.display())), - } -} - -pub(super) fn restore_file_snapshot(snapshot: &FileSnapshot) -> Result<(), String> { - if let Some(bytes) = snapshot.bytes.as_deref() { - return atomic_write(&snapshot.path, bytes); - } - match fs::remove_file(&snapshot.path) { - Ok(()) => Ok(()), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), - Err(error) => Err(format!( - "failed to remove {}: {error}", - snapshot.path.display() - )), - } -} - -pub(super) fn start_sidecar(agent: CodingAgent, url: &str, runtime: &Path) -> Result<(), String> { - if healthz(url) { - return Ok(()); - } - let (_, port) = parse_loopback_url(url)?; - let bind = format!("127.0.0.1:{port}"); - let relay = relay_binary()?; - let log_path = runtime.join(format!("{}-sidecar.log", agent.as_arg())); - let log = OpenOptions::new() - .create(true) - .append(true) - .open(&log_path) - .map_err(|error| format!("failed to open {}: {error}", log_path.display()))?; - let err_log = log - .try_clone() - .map_err(|error| format!("failed to clone sidecar log handle: {error}"))?; - let mut child = Command::new(relay) - .arg("--bind") - .arg(bind) - .env("NEMO_RELAY_PLUGIN_IDLE_TIMEOUT_SECS", plugin_idle_timeout()) - .stdin(Stdio::null()) - .stdout(Stdio::from(log)) - .stderr(Stdio::from(err_log)) - .spawn() - .map_err(|error| format!("failed to spawn nemo-relay sidecar: {error}"))?; - let pid_path = runtime.join(format!("{}-sidecar.pid", agent.as_arg())); - let _ = fs::write(&pid_path, child.id().to_string()); - for _ in 0..50 { - if healthz(url) { - return Ok(()); - } - match child.try_wait() { - Ok(Some(status)) => { - let _ = fs::remove_file(&pid_path); - return Err(format!( - "nemo-relay sidecar exited before becoming ready at {url}: {status}" - )); - } - Ok(None) => {} - Err(error) => { - let _ = fs::remove_file(&pid_path); - return Err(format!( - "failed to inspect nemo-relay sidecar process: {error}" - )); - } - } - thread::sleep(Duration::from_millis(50)); - } - terminate_unready_sidecar(child, &pid_path, url) -} - -pub(super) fn terminate_unready_sidecar( - mut child: std::process::Child, - pid_path: &Path, - url: &str, -) -> Result<(), String> { - match child.try_wait() { - Ok(Some(status)) => { - let _ = fs::remove_file(pid_path); - return Err(format!( - "nemo-relay sidecar exited before becoming ready at {url}: {status}" - )); - } - Ok(None) => {} - Err(error) => { - let _ = fs::remove_file(pid_path); - return Err(format!( - "failed to inspect nemo-relay sidecar process: {error}" - )); - } - } - if let Err(error) = child.kill() { - let _ = fs::remove_file(pid_path); - return Err(format!( - "nemo-relay sidecar did not become ready at {url}; failed to terminate startup process: {error}" - )); - } - let _ = child.wait(); - let _ = fs::remove_file(pid_path); - Err(format!( - "nemo-relay sidecar did not become ready at {url}; terminated startup process" - )) -} - -pub(super) fn post_hook(agent: CodingAgent, url: &str, payload: &[u8]) -> Result, String> { - let hook_path = match agent { - CodingAgent::ClaudeCode => "/hooks/claude-code", - CodingAgent::Codex => "/hooks/codex", - _ => { - return Err(format!( - "plugin shim hook forwarding supports claude and codex, got {}", - agent.as_arg() - )); - } - }; - let (host, port) = parse_loopback_url(url)?; - let addrs = (host.as_str(), port) - .to_socket_addrs() - .map_err(|error| format!("hook forward failed: {error}"))?; - let mut stream = None; - for addr in addrs { - match TcpStream::connect_timeout(&addr, Duration::from_secs(2)) { - Ok(candidate) => { - stream = Some(candidate); - break; - } - Err(_) => continue, - } - } - let Some(mut stream) = stream else { - return Err("hook forward failed: connection timed out".into()); - }; - stream - .set_read_timeout(Some(Duration::from_secs(2))) - .map_err(|error| format!("failed to set read timeout: {error}"))?; - stream - .set_write_timeout(Some(Duration::from_secs(2))) - .map_err(|error| format!("failed to set write timeout: {error}"))?; - let request = format!( - "POST {hook_path} HTTP/1.1\r\nHost: {host}:{port}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", - payload.len() - ); - stream - .write_all(request.as_bytes()) - .and_then(|_| stream.write_all(payload)) - .map_err(|error| format!("hook forward failed: {error}"))?; - let mut response = Vec::new(); - stream - .read_to_end(&mut response) - .map_err(|error| format!("hook forward failed: {error}"))?; - parse_http_response(&response) -} - -pub(super) fn parse_http_response(response: &[u8]) -> Result, String> { - let Some(split) = response.windows(4).position(|window| window == b"\r\n\r\n") else { - return Err("hook forward failed: malformed HTTP response".into()); - }; - let headers = &response[..split]; - let body = response[split + 4..].to_vec(); - let status_line = headers - .split(|byte| *byte == b'\n') - .next() - .and_then(|line| std::str::from_utf8(line).ok()) - .unwrap_or_default(); - let status_code = status_line - .split_whitespace() - .nth(1) - .and_then(|value| value.parse::().ok()); - if status_code.is_some_and(|code| (200..=299).contains(&code)) { - Ok(body) - } else { - Err(format!( - "nemo-relay hook forward failed with {}", - status_line.trim() - )) - } -} - -pub(super) fn healthz(url: &str) -> bool { - let Ok((host, port)) = parse_loopback_url(url) else { - return false; - }; - let Ok(addrs) = (host.as_str(), port).to_socket_addrs() else { - return false; - }; - let mut stream = None; - for addr in addrs { - match TcpStream::connect_timeout(&addr, HEALTHZ_TIMEOUT) { - Ok(candidate) => { - stream = Some(candidate); - break; - } - Err(_) => continue, - } - } - let Some(mut stream) = stream else { - return false; - }; - if stream.set_read_timeout(Some(HEALTHZ_TIMEOUT)).is_err() - || stream.set_write_timeout(Some(HEALTHZ_TIMEOUT)).is_err() - { - return false; - } - let request = - format!("GET /healthz HTTP/1.1\r\nHost: {host}:{port}\r\nConnection: close\r\n\r\n"); - if stream.write_all(request.as_bytes()).is_err() { - return false; - } - let mut response = [0_u8; 32]; - stream - .read(&mut response) - .ok() - .is_some_and(|count| response[..count].starts_with(b"HTTP/1.1 200")) -} - -pub(super) fn parse_loopback_url(url: &str) -> Result<(String, u16), String> { - let without_scheme = url - .strip_prefix("http://") - .ok_or_else(|| format!("plugin shim only supports http loopback URLs: {url}"))?; - let authority = without_scheme.split('/').next().unwrap_or(without_scheme); - let (host, port) = authority - .rsplit_once(':') - .ok_or_else(|| format!("missing port in gateway URL: {url}"))?; - if host != "127.0.0.1" && host != "localhost" { - return Err(format!( - "plugin shim only supports loopback gateway URLs: {url}" - )); - } - let port = port - .parse::() - .map_err(|error| format!("invalid gateway port in {url}: {error}"))?; - Ok((host.to_string(), port)) -} - -pub(super) fn gateway_url(agent: CodingAgent, explicit: Option<&str>) -> String { - if let Some(url) = explicit { - return url.to_string(); - } - if matches!(agent, CodingAgent::ClaudeCode) - && let Ok(url) = env::var("NEMO_RELAY_GATEWAY_URL") - { - return url; - } - env::var("NEMO_RELAY_PLUGIN_GATEWAY_URL").unwrap_or_else(|_| DEFAULT_URL.into()) -} - -pub(super) fn relay_binary() -> Result { - if let Ok(path) = env::var("NEMO_RELAY_PLUGIN_BINARY") { - let path = PathBuf::from(path); - if path.exists() { - return Ok(path); - } - return Err(format!( - "NEMO_RELAY_PLUGIN_BINARY does not exist: {}", - path.display() - )); - } - current_exe() -} - -pub(super) fn current_exe() -> Result { - env::current_exe().map_err(|error| format!("failed to resolve current executable: {error}")) -} - -pub(super) fn runtime_dir() -> PathBuf { - runtime_dir_for( - env::var_os("XDG_RUNTIME_DIR"), - env::var_os("TMPDIR"), - env::var_os("TEMP"), - env::temp_dir(), - env::var_os("USER"), - env::var_os("USERNAME"), - ) -} - -pub(super) fn runtime_dir_for( - xdg_runtime_dir: Option, - tmpdir: Option, - temp: Option, - temp_dir: PathBuf, - user: Option, - username: Option, -) -> PathBuf { - if let Some(base) = xdg_runtime_dir.or(tmpdir).or(temp) { - return PathBuf::from(base).join("nemo-relay-plugin"); - } - temp_dir - .join(runtime_user_segment(user, username)) - .join("nemo-relay-plugin") -} - -pub(super) fn sidecar_lock_name(url: &str) -> String { - let raw = Url::parse(url) - .ok() - .and_then(|parsed| { - let host = parsed.host_str()?; - let port = parsed.port_or_known_default()?; - Some(format!("{host}-{port}")) - }) - .unwrap_or_else(|| url.to_string()); - sanitize_filesystem_segment(&raw) -} - -fn runtime_user_segment( - user: Option, - username: Option, -) -> String { - let raw = user - .or(username) - .and_then(|value| value.into_string().ok()) - .unwrap_or_else(|| "unknown-user".into()); - sanitize_filesystem_segment(&raw) -} - -fn sanitize_filesystem_segment(raw: &str) -> String { - let sanitized: String = raw - .chars() - .map(|character| { - if character.is_ascii_alphanumeric() || matches!(character, '.' | '-' | '_') { - character - } else { - '_' - } - }) - .collect(); - if sanitized.is_empty() { - "unknown".into() - } else { - sanitized - } -} - -pub(super) fn plugin_idle_timeout() -> String { - env::var("NEMO_RELAY_PLUGIN_IDLE_TIMEOUT_SECS").unwrap_or_else(|_| "300".into()) -} - -pub(super) fn fail_closed() -> bool { - env::var("NEMO_RELAY_FAIL_CLOSED").ok().as_deref() == Some("1") -} - -pub(super) trait ExecOrStatus { - fn exec_or_status(&mut self) -> std::io::Result; -} - -#[cfg(unix)] -impl ExecOrStatus for Command { - fn exec_or_status(&mut self) -> std::io::Result { - use std::os::unix::process::CommandExt; - let error = self.exec(); - Err(error) - } -} - -#[cfg(not(unix))] -impl ExecOrStatus for Command { - fn exec_or_status(&mut self) -> std::io::Result { - let status = self.status()?; - Ok(status - .code() - .and_then(|code| u8::try_from(code).ok()) - .map(ExitCode::from) - .unwrap_or(ExitCode::FAILURE)) - } -} diff --git a/crates/cli/src/plugins/config_io.rs b/crates/cli/src/plugins/config_io.rs index c34acd192..226ba112d 100644 --- a/crates/cli/src/plugins/config_io.rs +++ b/crates/cli/src/plugins/config_io.rs @@ -6,22 +6,16 @@ use std::path::{Path, PathBuf}; use console::style; -use nemo_relay::plugin::dynamic::DynamicPluginManifest; use nemo_relay::plugin::{ConfigPolicy, PluginConfig, validate_plugin_config}; -use nemo_relay_adaptive::plugin_component::register_adaptive_component; -use nemo_relay_pii_redaction::component::register_pii_redaction_component; -#[cfg(feature = "switchyard")] -use nemo_relay_switchyard::{ - register_switchyard_component, validate_switchyard_atof_configuration, -}; use serde::Serialize; use serde_json::{Map, Value}; -use crate::config::{ - PluginsScopeArgs, global_plugin_config_path, project_plugin_config_path, - user_plugin_config_path, +use crate::configuration::{ + global_plugin_config_path, project_plugin_config_path, user_plugin_config_path, }; use crate::error::CliError; +use crate::plugins::ConfigurationScope; +use crate::server::register_and_validate_plugin_components; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum TargetScope { @@ -274,22 +268,14 @@ fn json_to_toml(value: Value) -> Result { }) } -pub(crate) fn target_scope(command: &PluginsScopeArgs) -> Result { - let selected = [command.user, command.project, command.global] - .into_iter() - .filter(|selected| *selected) - .count(); - if selected > 1 { - return Err(CliError::Config( +pub(crate) fn target_scope(command: &ConfigurationScope) -> Result { + match command { + ConfigurationScope::Default | ConfigurationScope::User => Ok(TargetScope::User), + ConfigurationScope::Project => Ok(TargetScope::Project), + ConfigurationScope::Global => Ok(TargetScope::Global), + ConfigurationScope::Invalid => Err(CliError::Config( "choose only one of --user, --project, or --global".into(), - )); - } - if command.project { - Ok(TargetScope::Project) - } else if command.global { - Ok(TargetScope::Global) - } else { - Ok(TargetScope::User) + )), } } @@ -428,7 +414,7 @@ pub(crate) fn remove_dynamic_plugin_reference( target_manifest_ref .as_ref() .is_some_and(|target_manifest_ref| manifest_ref == target_manifest_ref) - || DynamicPluginManifest::load_from_path(manifest_ref) + || crate::configuration::load_bounded_dynamic_plugin_manifest(manifest_ref) .map(|(manifest, _)| manifest.plugin.id.trim() == plugin_id) .unwrap_or(false) }); @@ -716,19 +702,12 @@ fn print_rendered_preview(rendered: &str) -> Result<(), CliError> { } pub(crate) fn validate_config(config: &PluginConfig) -> Result<(), CliError> { - register_adaptive_component().map_err(|error| { - CliError::Config(format!("adaptive plugin registration failed: {error}")) - })?; - register_pii_redaction_component().map_err(|error| { - CliError::Config(format!("PII redaction plugin registration failed: {error}")) - })?; - #[cfg(feature = "switchyard")] - register_switchyard_component().map_err(|error| { - CliError::Config(format!("Switchyard plugin registration failed: {error}")) - })?; - #[cfg(feature = "switchyard")] - validate_switchyard_atof_configuration(config) - .map_err(|error| CliError::Config(format!("Switchyard ATOF validation failed: {error}")))?; + if let Some(error) = register_and_validate_plugin_components(config) + .into_iter() + .next() + { + return Err(CliError::Config(error.to_string())); + } let report = validate_plugin_config(config); if report.has_errors() { let messages = report diff --git a/crates/cli/src/plugins/dynamic_editor.rs b/crates/cli/src/plugins/dynamic_editor.rs index 99111f7ab..2627c6e6c 100644 --- a/crates/cli/src/plugins/dynamic_editor.rs +++ b/crates/cli/src/plugins/dynamic_editor.rs @@ -282,13 +282,15 @@ fn load_dynamic_plugin_state( entry: DynamicPluginConfigEntry, plugin_ids: &mut HashSet, ) -> Result { - let (manifest, manifest_ref) = DynamicPluginManifest::load_from_path(&entry.manifest_path) - .map_err(|error| { - CliError::Config(format!( - "could not load dynamic plugin manifest '{}' for editing: {error}", - entry.manifest - )) - })?; + let (manifest, manifest_ref) = crate::configuration::load_bounded_dynamic_plugin_manifest( + &entry.manifest_path, + ) + .map_err(|error| { + CliError::Config(format!( + "could not load dynamic plugin manifest '{}' for editing: {error}", + entry.manifest + )) + })?; let plugin_id = manifest.plugin.id.trim().to_owned(); if !plugin_ids.insert(plugin_id.clone()) { return Err(CliError::Config(format!( diff --git a/crates/cli/src/plugins/lifecycle.rs b/crates/cli/src/plugins/lifecycle.rs deleted file mode 100644 index 8ee4d7b2c..000000000 --- a/crates/cli/src/plugins/lifecycle.rs +++ /dev/null @@ -1,1372 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -use std::collections::{BTreeSet, HashMap}; -use std::fmt; -use std::path::Path; -use std::path::PathBuf; -use std::process::ExitCode; - -use nemo_relay::plugin::dynamic::{ - DynamicPluginCheckState, DynamicPluginCompatibility, DynamicPluginFailure, - DynamicPluginFailurePhase, DynamicPluginKind, DynamicPluginLoadContract, DynamicPluginManifest, - DynamicPluginRecord, DynamicPluginValidationStatus, -}; -use serde_json::{Map, Value}; - -use crate::config::{ - PluginsAddCommand, PluginsDisableCommand, PluginsEnableCommand, PluginsInspectCommand, - PluginsListCommand, PluginsRemoveCommand, PluginsValidateCommand, ResolvedConfig, - ResolvedDynamicPluginConfig, ServerArgs, resolve_plugins_config, -}; -use crate::error::{CliError, PluginLifecycleFailureKind}; -use crate::plugins::policy::{ - EvaluatedDynamicPluginHostPolicy, evaluate_dynamic_plugin_host_policy, -}; - -use super::config_io::{ - append_dynamic_plugin_reference, remove_dynamic_plugin_reference, target_scope, -}; -use super::schema::PluginConfigSchema; - -mod environment; -mod responses; -mod state; -mod target; -mod trust; - -use self::environment::{ - ProcessPythonEnvironmentCommandRunner, PythonEnvironmentCommandRunner, environment_state, - provision_python_environment, remove_managed_environment, -}; -use self::responses::{ - ValidateResponseInput, failure, generic_failure, inspect_data, inspect_success, list_success, - print_response_json, validate_success, -}; -use self::state::{ - RegistryScope, ScopedDynamicPluginRecord, ScopedRegistry, collect_records, find_record_by_id, - load_scoped_registries, scoped_paths_for_add, -}; -use self::target::PluginTarget; -use self::trust::{EvaluatedDynamicPluginTrust, evaluate_dynamic_plugin_trust}; - -const VALIDATION_MESSAGE: &str = "validated by CLI"; - -pub(crate) fn add(command: PluginsAddCommand, server: &ServerArgs) -> Result<(), CliError> { - add_with_environment_runner(command, server, &ProcessPythonEnvironmentCommandRunner) -} - -fn add_with_environment_runner( - command: PluginsAddCommand, - server: &ServerArgs, - environment_runner: &impl PythonEnvironmentCommandRunner, -) -> Result<(), CliError> { - const COMMAND: &str = "plugins add"; - - let resolved = resolve_plugins_config(server.config.as_ref())?; - let mut scopes = load_and_hydrate_scopes(server.config.as_ref(), &resolved)?; - let (manifest, manifest_ref) = load_manifest_for_action("add", &command.path)?; - let plugin_id = manifest.plugin.id.trim().to_owned(); - load_config_schema_for_manifest(&manifest, &manifest_ref)?; - let revived = match find_record_by_id(&scopes, &plugin_id)? { - Some(existing) if !existing.record.is_tombstoned() => { - return Err(CliError::Config(format!( - "dynamic plugin '{}' is already registered in the {} lifecycle scope", - plugin_id, existing.scope - ))); - } - Some(_) => true, - None => false, - }; - - if server.config.is_some() && scope_flags_selected(&command.scope) { - return Err(CliError::Config( - "--config cannot be combined with --user, --project, or --global for `plugins add`" - .into(), - )); - } - - let (plugins_toml_path, state_path, scope) = - scoped_paths_for_add(target_scope(&command.scope)?, server.config.as_ref())?; - let scope_index = ensure_scope(&mut scopes, scope, plugins_toml_path.clone(), state_path); - let policy = evaluate_dynamic_plugin_host_policy(&resolved.dynamic_plugin_policy, &manifest); - let trust = evaluate_dynamic_plugin_trust(&manifest, &manifest_ref, &policy); - if !policy.policy_satisfied { - return Err(plugin_refused_with_code( - COMMAND, - Some(plugin_id.clone()), - "policy_blocked", - policy - .failure() - .map(|failure| failure.display(&plugin_id).to_string()) - .unwrap_or_else(|| { - format!("dynamic plugin '{}' is blocked by host policy", plugin_id) - }), - )); - } - if let Some(failure) = trust.failure() { - return Err(plugin_refused_with_code( - COMMAND, - Some(plugin_id.clone()), - trust_refusal_code(&trust), - failure.display(&plugin_id).to_string(), - )); - } - let environment_ref = provision_python_environment( - &manifest, - &manifest_ref, - &scopes[scope_index].state_path, - environment_runner, - ) - .map_err(|message| { - plugin_failed_with_code( - COMMAND, - Some(plugin_id.clone()), - "environment_failed", - message, - ) - })?; - let environment_ref_string = environment_ref - .as_ref() - .map(|environment| environment.display().to_string()); - let record = match validated_record_from_manifest( - manifest, - manifest_ref.clone(), - environment_ref_string, - &scopes[scope_index].state_path, - &policy, - &trust, - ) { - Ok(record) => record, - Err(error) => { - cleanup_provisioned_environment( - &scopes[scope_index].state_path, - &plugin_id, - environment_ref.as_deref(), - ); - return Err(error); - } - }; - let original_plugins_toml = std::fs::read(&plugins_toml_path).ok(); - - if let Err(error) = scopes[scope_index] - .registry - .add(record) - .map_err(|error| CliError::Config(error.to_string())) - { - cleanup_provisioned_environment( - &scopes[scope_index].state_path, - &plugin_id, - environment_ref.as_deref(), - ); - return Err(error); - } - if let Err(error) = append_dynamic_plugin_reference(&plugins_toml_path, &manifest_ref) { - cleanup_provisioned_environment( - &scopes[scope_index].state_path, - &plugin_id, - environment_ref.as_deref(), - ); - return Err(error); - } - if let Err(error) = scopes[scope_index].save() { - let _ = restore_plugins_toml(&plugins_toml_path, original_plugins_toml.as_deref()); - cleanup_provisioned_environment( - &scopes[scope_index].state_path, - &plugin_id, - environment_ref.as_deref(), - ); - return Err(error); - } - - println!( - "{} dynamic plugin {}", - if revived { "Revived" } else { "Added" }, - plugin_id - ); - Ok(()) -} - -fn cleanup_provisioned_environment(state_path: &Path, plugin_id: &str, environment: Option<&Path>) { - if let Some(environment) = environment { - let _ = remove_managed_environment( - state_path, - plugin_id, - environment.to_string_lossy().as_ref(), - ); - } -} - -pub(crate) fn enforce_required_dynamic_plugin_startup( - explicit: Option<&PathBuf>, - resolved: &ResolvedConfig, -) -> Result<(), CliError> { - let (scopes, touched_scope_indices) = load_and_hydrate_scopes_with_updates(explicit, resolved)?; - for scope_index in touched_scope_indices { - scopes[scope_index].save()?; - } - let required_failures = collect_records(&scopes, false) - .into_iter() - .filter(|entry| entry.record.spec.enabled) - .filter_map(|entry| required_startup_failure(&entry, resolved.dynamic_plugins.as_slice())) - .collect::>(); - - if required_failures.is_empty() { - return Ok(()); - } - - Err(CliError::Config(format!( - "required dynamic plugin startup preflight failed:\n{}", - required_failures.join("\n") - ))) -} - -pub(crate) fn validate( - command: PluginsValidateCommand, - server: &ServerArgs, -) -> Result<(), CliError> { - match PluginTarget::parse(&command.target) { - PluginTarget::Path(path) => { - if !path.exists() { - return Err(plugin_not_found( - "plugins validate", - Some(command.target.clone()), - format!("dynamic plugin target '{}' does not exist", command.target), - )); - } - let resolved = resolve_plugins_config(server.config.as_ref())?; - let (manifest, manifest_ref) = load_manifest_for_action("validate", &path)?; - load_config_schema_for_manifest(&manifest, &manifest_ref)?; - let policy = - evaluate_dynamic_plugin_host_policy(&resolved.dynamic_plugin_policy, &manifest); - let trust = evaluate_dynamic_plugin_trust(&manifest, &manifest_ref, &policy); - if command.json { - print_response_json(&validate_success(ValidateResponseInput { - command: "plugins validate", - target: Some(command.target.as_str()), - target_kind: "path", - resolved_plugin_id: Some(manifest.plugin.id.as_str()), - manifest: &manifest, - manifest_ref: &manifest_ref, - entry: None, - host_config: None, - policy: &policy, - trust: &trust, - }))?; - } else { - println!( - "{}", - PluginValidationSummaryView { - manifest: &manifest, - manifest_ref: &manifest_ref, - entry: None, - host_config: None, - policy: &policy, - trust: &trust, - } - ); - } - Ok(()) - } - PluginTarget::Id(plugin_id) => { - let resolved = resolve_plugins_config(server.config.as_ref())?; - let host_config_by_id = host_config_by_id(&resolved); - let mut scopes = load_and_hydrate_scopes(server.config.as_ref(), &resolved)?; - let entry = find_registered_entry(&scopes, "plugins validate", &plugin_id)?; - let manifest_ref = manifest_ref_from_record(&entry.record)?; - let (manifest, manifest_ref) = load_manifest_for_action("validate", &manifest_ref)?; - if let Some(schema) = load_config_schema_for_manifest(&manifest, &manifest_ref)? { - let config = host_config_by_id - .get(&plugin_id) - .map(|host_config| Value::Object(host_config.config.clone())) - .unwrap_or_else(|| Value::Object(Map::new())); - schema.validate(&config)?; - } - let policy = - evaluate_dynamic_plugin_host_policy(&resolved.dynamic_plugin_policy, &manifest); - let trust = evaluate_dynamic_plugin_trust(&manifest, &manifest_ref, &policy); - update_registry_validation_status( - &mut scopes[entry.scope_index], - &plugin_id, - &manifest, - &policy, - &trust, - )?; - scopes[entry.scope_index].save()?; - let refreshed = find_record_by_id(&scopes, &plugin_id)? - .expect("validated registry record should still exist"); - if command.json { - print_response_json(&validate_success(ValidateResponseInput { - command: "plugins validate", - target: Some(plugin_id.as_str()), - target_kind: "plugin_id", - resolved_plugin_id: Some(plugin_id.as_str()), - manifest: &manifest, - manifest_ref: &manifest_ref, - entry: Some(&refreshed), - host_config: host_config_by_id.get(&plugin_id), - policy: &policy, - trust: &trust, - }))?; - } else { - println!( - "{}", - PluginValidationSummaryView { - manifest: &manifest, - manifest_ref: &manifest_ref, - entry: Some(&refreshed), - host_config: host_config_by_id.get(&plugin_id), - policy: &policy, - trust: &trust, - } - ); - } - Ok(()) - } - } -} - -pub(crate) fn list(command: PluginsListCommand, server: &ServerArgs) -> Result<(), CliError> { - let resolved = resolve_plugins_config(server.config.as_ref())?; - let host_config_by_id = host_config_by_id(&resolved); - let scopes = load_and_hydrate_scopes(server.config.as_ref(), &resolved)?; - let records = collect_records(&scopes, command.all); - if records.is_empty() { - if command.json { - print_response_json(&list_success( - "plugins list", - None, - &records, - &host_config_by_id, - ))?; - } else { - println!("No dynamic plugins registered."); - } - return Ok(()); - } - if command.json { - print_response_json(&list_success( - "plugins list", - None, - &records, - &host_config_by_id, - ))?; - } else { - println!( - "{}", - PluginListView { - records: &records, - host_config_by_id: &host_config_by_id, - } - ); - } - Ok(()) -} - -pub(crate) fn inspect(command: PluginsInspectCommand, server: &ServerArgs) -> Result<(), CliError> { - let resolved = resolve_plugins_config(server.config.as_ref())?; - let host_config_by_id = host_config_by_id(&resolved); - let scopes = load_and_hydrate_scopes(server.config.as_ref(), &resolved)?; - let entry = find_registered_entry(&scopes, "plugins inspect", &command.id)?; - let manifest_ref = manifest_ref_from_record(&entry.record)?; - let (manifest, manifest_ref) = load_manifest_for_action("inspect", &manifest_ref)?; - if command.json { - print_response_json(&inspect_success( - "plugins inspect", - command.id.as_str(), - &entry, - &manifest, - &manifest_ref, - host_config_by_id.get(&command.id), - ))?; - } else { - println!( - "{}", - PluginInspectView { - entry: &entry, - manifest: &manifest, - manifest_ref: &manifest_ref, - host_config: host_config_by_id.get(&command.id), - } - ); - } - Ok(()) -} - -pub(crate) fn enable(command: PluginsEnableCommand, server: &ServerArgs) -> Result<(), CliError> { - mutate_enabled_state(command.id, server, true) -} - -pub(crate) fn disable(command: PluginsDisableCommand, server: &ServerArgs) -> Result<(), CliError> { - mutate_enabled_state(command.id, server, false) -} - -pub(crate) fn remove(command: PluginsRemoveCommand, server: &ServerArgs) -> Result<(), CliError> { - let mut scopes = load_scoped_registries(server.config.as_ref())?; - if find_record_by_id(&scopes, &command.id)?.is_none() { - let resolved = resolve_plugins_config(server.config.as_ref())?; - scopes = load_and_hydrate_scopes(server.config.as_ref(), &resolved)?; - } - let entry = find_registered_entry(&scopes, "plugins remove", &command.id)?; - let original_plugins_toml = std::fs::read(&entry.plugins_toml_path).ok(); - let environment_ref = entry.record.source.environment_ref.clone(); - - scopes[entry.scope_index] - .registry - .remove(&command.id) - .map_err(|error| CliError::Config(error.to_string()))?; - remove_dynamic_plugin_reference( - &entry.plugins_toml_path, - &command.id, - entry.record.source.manifest_ref.as_deref(), - )?; - if let Err(error) = scopes[entry.scope_index].save() { - let _ = restore_plugins_toml(&entry.plugins_toml_path, original_plugins_toml.as_deref()); - return Err(error); - } - - if let Some(environment_ref) = environment_ref { - remove_managed_environment(&entry.state_path, &command.id, &environment_ref) - .map_err(CliError::Config)?; - scopes[entry.scope_index] - .registry - .update_environment(&command.id, None, DynamicPluginCheckState::Unknown) - .map_err(|error| CliError::Config(error.to_string()))?; - scopes[entry.scope_index].save()?; - } - - println!("Removed dynamic plugin {}", command.id); - Ok(()) -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) struct ActiveDynamicPluginComponent { - pub(crate) plugin_id: String, - pub(crate) kind: DynamicPluginKind, - pub(crate) manifest_ref: Option, - pub(crate) environment_ref: Option, - pub(crate) config: Map, -} - -pub(crate) fn active_dynamic_plugin_components( - explicit: Option<&PathBuf>, - resolved: &ResolvedConfig, -) -> Result, CliError> { - let scopes = load_and_hydrate_scopes(explicit, resolved)?; - let host_config_by_id = host_config_by_id(resolved); - let mut components = Vec::new(); - - for resolved_plugin in &resolved.dynamic_plugins { - let Some(entry) = find_record_by_id(&scopes, &resolved_plugin.plugin_id)? else { - return Err(CliError::Config(format!( - "dynamic plugin '{}' is present in resolved config but not lifecycle state", - resolved_plugin.plugin_id - ))); - }; - if entry.record.is_tombstoned() || !entry.record.spec.enabled { - continue; - } - let host_config = host_config_by_id - .get(&entry.record.metadata.id) - .ok_or_else(|| { - CliError::Config(format!( - "dynamic plugin '{}' is enabled but has no resolved host config", - entry.record.metadata.id - )) - })?; - components.push(ActiveDynamicPluginComponent { - plugin_id: entry.record.metadata.id.clone(), - kind: entry.record.metadata.kind, - manifest_ref: match entry.record.metadata.kind { - DynamicPluginKind::RustDynamic => Some(manifest_ref_from_record(&entry.record)?), - DynamicPluginKind::Worker => entry.record.source.manifest_ref.clone(), - }, - environment_ref: entry.record.source.environment_ref.clone(), - config: host_config.config.clone(), - }); - } - - Ok(components) -} - -fn mutate_enabled_state( - plugin_id: String, - server: &ServerArgs, - enabled: bool, -) -> Result<(), CliError> { - let command = if enabled { - "plugins enable" - } else { - "plugins disable" - }; - let mut scopes = if enabled { - let resolved = resolve_plugins_config(server.config.as_ref())?; - let mut scopes = load_and_hydrate_scopes(server.config.as_ref(), &resolved)?; - let entry = find_registered_entry(&scopes, command, &plugin_id)?; - if entry.record.is_tombstoned() { - return Err(plugin_refused( - command, - Some(plugin_id.clone()), - format!( - "dynamic plugin '{}' is tombstoned and cannot be {}d", - plugin_id, - if enabled { "enable" } else { "disable" } - ), - )); - } - let manifest_ref = manifest_ref_from_record(&entry.record)?; - let (manifest, manifest_ref) = load_manifest_for_action(command, &manifest_ref)?; - let policy = - evaluate_dynamic_plugin_host_policy(&resolved.dynamic_plugin_policy, &manifest); - let trust = evaluate_dynamic_plugin_trust(&manifest, &manifest_ref, &policy); - update_registry_validation_status( - &mut scopes[entry.scope_index], - &plugin_id, - &manifest, - &policy, - &trust, - )?; - if !policy.policy_satisfied { - scopes[entry.scope_index].save()?; - return Err(plugin_refused_with_code( - command, - Some(plugin_id.clone()), - "policy_blocked", - policy - .failure() - .map(|failure| failure.display(&plugin_id).to_string()) - .unwrap_or_else(|| { - format!("dynamic plugin '{}' is blocked by host policy", plugin_id) - }), - )); - } - if let Some(failure) = trust.failure() { - scopes[entry.scope_index].save()?; - return Err(plugin_refused_with_code( - command, - Some(plugin_id.clone()), - trust_refusal_code(&trust), - failure.display(&plugin_id).to_string(), - )); - } - if let Some(environment_error) = scopes[entry.scope_index] - .registry - .get(&plugin_id) - .and_then(|record| record.status.last_error.as_ref()) - .filter(|error| error.code == "environment_failed") - { - let message = environment_error.message.clone(); - scopes[entry.scope_index].save()?; - return Err(plugin_refused_with_code( - command, - Some(plugin_id.clone()), - "environment_failed", - message, - )); - } - scopes - } else { - load_scoped_registries(server.config.as_ref())? - }; - let entry = find_registered_entry(&scopes, command, &plugin_id)?; - if entry.record.is_tombstoned() { - return Err(plugin_refused( - command, - Some(plugin_id.clone()), - format!( - "dynamic plugin '{}' is tombstoned and cannot be {}d", - plugin_id, - if enabled { "enable" } else { "disable" } - ), - )); - } - if enabled { - scopes[entry.scope_index] - .registry - .enable(&plugin_id) - .map_err(|error| CliError::Config(error.to_string()))?; - } else { - scopes[entry.scope_index] - .registry - .disable(&plugin_id) - .map_err(|error| CliError::Config(error.to_string()))?; - } - scopes[entry.scope_index].save()?; - - println!( - "{} dynamic plugin {}", - if enabled { "Enabled" } else { "Disabled" }, - plugin_id - ); - Ok(()) -} - -fn load_and_hydrate_scopes( - explicit: Option<&PathBuf>, - resolved: &ResolvedConfig, -) -> Result, CliError> { - let (scopes, touched_scope_indices) = load_and_hydrate_scopes_with_updates(explicit, resolved)?; - for scope_index in touched_scope_indices { - scopes[scope_index].save()?; - } - Ok(scopes) -} - -fn load_and_hydrate_scopes_with_updates( - explicit: Option<&PathBuf>, - resolved: &ResolvedConfig, -) -> Result<(Vec, Vec), CliError> { - let mut scopes = load_scoped_registries(explicit)?; - let mut touched_scope_indices = BTreeSet::new(); - for plugin in &resolved.dynamic_plugins { - let scope_index = scopes - .iter() - .position(|scope| scope.plugins_toml_path == plugin.source) - .ok_or_else(|| { - CliError::Config(format!( - "dynamic plugin '{}' resolved from {} but no matching lifecycle scope exists", - plugin.plugin_id, - plugin.source.display() - )) - })?; - touched_scope_indices.insert(scope_index); - let (manifest, manifest_ref) = load_manifest_for_action("hydrate", &plugin.manifest_ref)?; - let policy = - evaluate_dynamic_plugin_host_policy(&resolved.dynamic_plugin_policy, &manifest); - let trust = evaluate_dynamic_plugin_trust(&manifest, &manifest_ref, &policy); - if find_record_by_id(&scopes, &plugin.plugin_id)?.is_some() { - update_registry_validation_status( - &mut scopes[scope_index], - &plugin.plugin_id, - &manifest, - &policy, - &trust, - )?; - } else { - let state_path = scopes[scope_index].state_path.clone(); - let record = validated_record_from_manifest( - manifest, - manifest_ref, - None, - &state_path, - &policy, - &trust, - )?; - scopes[scope_index] - .registry - .add(record) - .map_err(|error| CliError::Config(error.to_string()))?; - } - } - Ok((scopes, touched_scope_indices.into_iter().collect())) -} - -fn validated_record_from_manifest( - manifest: DynamicPluginManifest, - manifest_ref: String, - environment_ref: Option, - state_path: &Path, - policy: &EvaluatedDynamicPluginHostPolicy, - trust: &EvaluatedDynamicPluginTrust, -) -> Result { - let environment = environment_state(&manifest, state_path, environment_ref.as_deref()); - let mut record = manifest - .into_record(Some(manifest_ref)) - .map_err(|error| CliError::Config(error.to_string()))?; - record.source.environment_ref = environment_ref; - record.status.validation = DynamicPluginValidationStatus { - manifest: DynamicPluginCheckState::Valid, - compatibility: DynamicPluginCheckState::Valid, - integrity: trust.integrity, - environment, - authenticity: trust.authenticity, - policy_satisfied: policy.check_state(), - checked_at: None, - message: Some(VALIDATION_MESSAGE.into()), - }; - record.status.startup_class = Some(policy.startup_class); - record.status.attestation_mode = Some(policy.attestation_mode); - record.status.last_error = policy - .last_error(&record.metadata.id) - .or_else(|| trust.last_error(&record.metadata.id)) - .or_else(|| { - environment_last_error( - &record.metadata.id, - environment, - record.source.environment_ref.as_deref(), - ) - }); - Ok(record) -} - -fn host_config_by_id(resolved: &ResolvedConfig) -> HashMap { - resolved - .dynamic_plugins - .iter() - .cloned() - .map(|plugin| (plugin.plugin_id.clone(), plugin)) - .collect() -} - -fn update_registry_policy_status( - scope: &mut ScopedRegistry, - plugin_id: &str, - policy: &EvaluatedDynamicPluginHostPolicy, -) -> Result<(), CliError> { - scope - .registry - .update_policy_status( - plugin_id, - policy.check_state(), - policy.startup_class, - policy.attestation_mode, - policy.last_error(plugin_id), - ) - .map_err(|error| CliError::Config(error.to_string())) -} - -fn update_registry_validation_status( - scope: &mut ScopedRegistry, - plugin_id: &str, - manifest: &DynamicPluginManifest, - policy: &EvaluatedDynamicPluginHostPolicy, - trust: &EvaluatedDynamicPluginTrust, -) -> Result<(), CliError> { - let environment_ref = scope - .registry - .get(plugin_id) - .and_then(|record| record.source.environment_ref.as_deref()); - let environment = environment_state(manifest, &scope.state_path, environment_ref); - let environment_error = environment_last_error(plugin_id, environment, environment_ref); - scope - .registry - .update_validation_status( - plugin_id, - DynamicPluginValidationStatus { - manifest: DynamicPluginCheckState::Valid, - compatibility: DynamicPluginCheckState::Valid, - integrity: trust.integrity, - environment, - authenticity: trust.authenticity, - policy_satisfied: policy.check_state(), - checked_at: None, - message: Some(VALIDATION_MESSAGE.into()), - }, - ) - .map_err(|error| CliError::Config(error.to_string()))?; - update_registry_policy_status(scope, plugin_id, policy)?; - scope - .registry - .update_last_error( - plugin_id, - policy - .last_error(plugin_id) - .or_else(|| trust.last_error(plugin_id)) - .or(environment_error), - ) - .map_err(|error| CliError::Config(error.to_string())) -} - -fn environment_last_error( - plugin_id: &str, - environment: DynamicPluginCheckState, - environment_ref: Option<&str>, -) -> Option { - (environment == DynamicPluginCheckState::Invalid).then(|| DynamicPluginFailure { - phase: DynamicPluginFailurePhase::Validation, - code: "environment_failed".into(), - message: environment_ref.map_or_else( - || { - format!( - "dynamic plugin '{}' has no lifecycle-managed Python environment; run `nemo-relay plugins remove {}` to remove the manual registration, then run `nemo-relay plugins add `", - plugin_id, plugin_id - ) - }, - |environment_ref| { - format!( - "dynamic plugin '{}' configured Python environment {} is unavailable", - plugin_id, environment_ref - ) - }, - ), - }) -} - -fn find_registered_entry( - scopes: &[ScopedRegistry], - command: &'static str, - plugin_id: &str, -) -> Result { - find_record_by_id(scopes, plugin_id)?.ok_or_else(|| { - plugin_not_found( - command, - Some(plugin_id.to_owned()), - format!( - "dynamic plugin '{}' is not registered; run `nemo-relay plugins add `", - plugin_id - ), - ) - }) -} - -fn load_manifest_for_action( - action: &str, - path: impl Into, -) -> Result<(DynamicPluginManifest, String), CliError> { - let path = path.into(); - DynamicPluginManifest::load_from_path(&path) - .map_err(|error| CliError::Config(format!("dynamic plugin {action} failed: {error}"))) -} - -fn load_config_schema_for_manifest( - manifest: &DynamicPluginManifest, - manifest_ref: &str, -) -> Result, CliError> { - let schema_path = manifest - .resolve_config_schema_path(manifest_ref) - .map_err(|error| { - CliError::Config(format!( - "dynamic plugin '{}' config schema path could not be resolved from '{}': {error}", - manifest.plugin.id, manifest_ref - )) - })?; - schema_path - .map(|path| PluginConfigSchema::load(manifest.plugin.id.trim(), path)) - .transpose() -} - -fn manifest_ref_from_record(record: &DynamicPluginRecord) -> Result { - record.source.manifest_ref.clone().ok_or_else(|| { - CliError::Config(format!( - "dynamic plugin '{}' has no manifest_ref in lifecycle state", - record.metadata.id - )) - }) -} - -fn ensure_scope( - scopes: &mut Vec, - scope: RegistryScope, - plugins_toml_path: PathBuf, - state_path: PathBuf, -) -> usize { - if let Some(index) = scopes.iter().position(|existing| { - existing.scope == scope - && existing.plugins_toml_path == plugins_toml_path - && existing.state_path == state_path - }) { - return index; - } - scopes.push(ScopedRegistry { - scope, - plugins_toml_path, - state_path, - registry: nemo_relay::plugin::dynamic::DynamicPluginRegistry::new(), - }); - scopes.len() - 1 -} - -fn scope_flags_selected(scope: &crate::config::PluginsScopeArgs) -> bool { - scope.user || scope.project || scope.global -} - -fn restore_plugins_toml(path: &std::path::Path, original: Option<&[u8]>) -> Result<(), CliError> { - match original { - Some(bytes) => std::fs::write(path, bytes)?, - None if path.exists() => std::fs::remove_file(path)?, - None => {} - } - Ok(()) -} - -fn required_startup_failure( - entry: &ScopedDynamicPluginRecord, - resolved_plugins: &[ResolvedDynamicPluginConfig], -) -> Option { - if entry.record.status.startup_class - != Some(nemo_relay::plugin::dynamic::DynamicPluginStartupClass::Required) - { - return None; - } - - if entry.record.status.validation.policy_satisfied == DynamicPluginCheckState::Invalid { - return Some(format!( - "- {}: {}", - entry.record.metadata.id, - entry - .record - .status - .last_error - .as_ref() - .map(|error| error.message.as_str()) - .unwrap_or("blocked by host policy") - )); - } - if entry.record.status.validation.integrity == DynamicPluginCheckState::Invalid - || entry.record.status.validation.authenticity == DynamicPluginCheckState::Invalid - { - return Some(format!( - "- {}: {}", - entry.record.metadata.id, - entry - .record - .status - .last_error - .as_ref() - .map(|error| error.message.as_str()) - .unwrap_or("required dynamic plugin trust verification failed") - )); - } - if entry.record.status.validation.environment == DynamicPluginCheckState::Invalid { - return Some(format!( - "- {}: {}", - entry.record.metadata.id, - entry - .record - .status - .last_error - .as_ref() - .map(|error| error.message.as_str()) - .unwrap_or("required dynamic plugin environment is unavailable") - )); - } - - let manifest_ref = entry - .record - .source - .manifest_ref - .as_deref() - .map(Path::new) - .map(Path::to_path_buf); - if manifest_ref.is_none() { - return Some(format!( - "- {}: required dynamic plugin has no manifest_ref in lifecycle state", - entry.record.metadata.id - )); - } - - let manifest_ref = manifest_ref.expect("manifest_ref checked above"); - if !resolved_plugins - .iter() - .any(|plugin| plugin.plugin_id == entry.record.metadata.id) - { - if !manifest_ref.exists() { - return Some(format!( - "- {}: required dynamic plugin manifest is no longer available at {}", - entry.record.metadata.id, - manifest_ref.display() - )); - } - - if let Err(error) = DynamicPluginManifest::load_from_path(&manifest_ref) { - return Some(format!( - "- {}: required dynamic plugin manifest at {} is unreadable: {}", - entry.record.metadata.id, - manifest_ref.display(), - error - )); - } - } - - None -} - -pub(crate) fn render_plugin_error( - error: &CliError, - json: bool, -) -> Result, CliError> { - let Some((command, target, kind, code, message)) = error.as_plugin_lifecycle_error_context() - else { - return Ok(None); - }; - - let exit_code = match kind { - PluginLifecycleFailureKind::Failed => ExitCode::from(1), - PluginLifecycleFailureKind::NotFound => ExitCode::from(2), - PluginLifecycleFailureKind::Refused => ExitCode::from(3), - }; - - if json { - print_response_json(&failure(command, target, kind, code, message))?; - } else { - eprintln!("{message}"); - } - Ok(Some(exit_code)) -} - -pub(crate) fn render_generic_plugin_json_error( - command: &'static str, - target: Option<&str>, - message: &str, -) -> Result { - print_response_json(&generic_failure(command, target, message))?; - Ok(ExitCode::from(1)) -} - -fn plugin_not_found( - command: &'static str, - target: Option, - message: impl Into, -) -> CliError { - CliError::PluginLifecycle { - command, - target, - kind: PluginLifecycleFailureKind::NotFound, - code: None, - message: message.into(), - } -} - -fn plugin_refused( - command: &'static str, - target: Option, - message: impl Into, -) -> CliError { - plugin_refused_with_code(command, target, "refused", message) -} - -fn plugin_refused_with_code( - command: &'static str, - target: Option, - code: &'static str, - message: impl Into, -) -> CliError { - CliError::PluginLifecycle { - command, - target, - kind: PluginLifecycleFailureKind::Refused, - code: Some(code), - message: message.into(), - } -} - -fn plugin_failed_with_code( - command: &'static str, - target: Option, - code: &'static str, - message: impl Into, -) -> CliError { - CliError::PluginLifecycle { - command, - target, - kind: PluginLifecycleFailureKind::Failed, - code: Some(code), - message: message.into(), - } -} - -fn trust_refusal_code(trust: &EvaluatedDynamicPluginTrust) -> &'static str { - trust.refusal_code().unwrap_or("refused") -} - -fn list_validation_state(record: &DynamicPluginRecord) -> DynamicPluginCheckState { - let validation = &record.status.validation; - if validation.manifest == DynamicPluginCheckState::Invalid - || validation.compatibility == DynamicPluginCheckState::Invalid - || validation.integrity == DynamicPluginCheckState::Invalid - || validation.environment == DynamicPluginCheckState::Invalid - || validation.authenticity == DynamicPluginCheckState::Invalid - || validation.policy_satisfied == DynamicPluginCheckState::Invalid - { - DynamicPluginCheckState::Invalid - } else if validation.manifest == DynamicPluginCheckState::Unknown - || validation.compatibility == DynamicPluginCheckState::Unknown - { - DynamicPluginCheckState::Unknown - } else { - DynamicPluginCheckState::Valid - } -} - -struct PluginListView<'a> { - records: &'a [ScopedDynamicPluginRecord], - host_config_by_id: &'a HashMap, -} - -impl fmt::Display for PluginListView<'_> { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let widths = PluginListWidths::from_records(self.records); - - write!( - f, - "{: Self { - Self { - id: column_width( - "ID", - records - .iter() - .map(|entry| entry.record.metadata.id.as_str()), - ), - scope: column_width( - "SCOPE", - records.iter().map(|entry| { - let scope: &'static str = entry.scope.into(); - scope - }), - ), - enabled: column_width( - "ENABLED", - records.iter().map(|entry| { - if entry.record.spec.enabled { - "true" - } else { - "false" - } - }), - ), - state: column_width( - "STATE", - records - .iter() - .map(|entry| lifecycle_state_label(&entry.record)), - ), - validation: column_width( - "VALIDATION", - records.iter().map(|entry| { - let validation: &'static str = list_validation_state(&entry.record).into(); - validation - }), - ), - policy: column_width( - "POLICY", - records.iter().map(|entry| { - let policy: &'static str = - entry.record.status.validation.policy_satisfied.into(); - policy - }), - ), - } - } -} - -fn column_width<'a>(header: &'static str, values: impl Iterator) -> usize { - values - .map(str::len) - .chain(std::iter::once(header.len())) - .max() - .unwrap_or(header.len()) -} - -struct PluginInspectView<'a> { - entry: &'a ScopedDynamicPluginRecord, - manifest: &'a DynamicPluginManifest, - manifest_ref: &'a str, - host_config: Option<&'a ResolvedDynamicPluginConfig>, -} - -impl fmt::Display for PluginInspectView<'_> { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let view = inspect_data( - self.entry, - self.manifest, - self.manifest_ref, - self.host_config, - ); - let yaml = serde_yaml::to_string(&view).map_err(|_| fmt::Error)?; - write!(f, "{}", yaml.trim_end()) - } -} - -struct PluginValidationSummaryView<'a> { - manifest: &'a DynamicPluginManifest, - manifest_ref: &'a str, - entry: Option<&'a ScopedDynamicPluginRecord>, - host_config: Option<&'a ResolvedDynamicPluginConfig>, - policy: &'a EvaluatedDynamicPluginHostPolicy, - trust: &'a EvaluatedDynamicPluginTrust, -} - -impl fmt::Display for PluginValidationSummaryView<'_> { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let environment = self - .entry - .map(|entry| entry.record.status.validation.environment) - .unwrap_or(DynamicPluginCheckState::Unknown); - if self.policy.policy_satisfied - && self.trust.is_satisfied() - && environment != DynamicPluginCheckState::Invalid - { - writeln!(f, "Dynamic plugin '{}' is valid.", self.manifest.plugin.id)?; - } else if self.policy.policy_satisfied - && self.trust.is_satisfied() - && environment == DynamicPluginCheckState::Invalid - { - writeln!( - f, - "Dynamic plugin '{}' manifest is valid, but its runtime environment is unavailable.", - self.manifest.plugin.id - )?; - } else if self.policy.policy_satisfied { - writeln!( - f, - "Dynamic plugin '{}' manifest is valid, but trust verification blocks it.", - self.manifest.plugin.id - )?; - } else { - writeln!( - f, - "Dynamic plugin '{}' manifest is valid, but host policy blocks it.", - self.manifest.plugin.id - )?; - } - writeln!(f, "kind: {}", self.manifest.plugin.kind)?; - writeln!( - f, - "policy_state: {}", - <&'static str>::from(self.policy.check_state()) - )?; - writeln!( - f, - "integrity_state: {}", - <&'static str>::from(self.trust.integrity) - )?; - writeln!( - f, - "environment_state: {}", - <&'static str>::from(environment) - )?; - writeln!( - f, - "authenticity_state: {}", - <&'static str>::from(self.trust.authenticity) - )?; - writeln!(f, "startup_class: {}", self.policy.startup_class)?; - writeln!(f, "attestation_mode: {}", self.policy.attestation_mode)?; - if let Some(failure) = self.policy.failure() { - writeln!( - f, - "policy_error: {}", - failure.display(&self.manifest.plugin.id) - )?; - } - if let Some(failure) = self.trust.failure() { - writeln!( - f, - "trust_error: {}", - failure.display(&self.manifest.plugin.id) - )?; - } - if let Some(entry) = self.entry { - writeln!(f, "manifest: {}", self.manifest_ref)?; - writeln!(f, "scope: {}", entry.scope)?; - writeln!(f, "lifecycle_state_path: {}", entry.state_path.display())?; - writeln!(f, "desired.enabled: {}", entry.record.spec.enabled)?; - write!(f, "host_config: {}", host_config_label(self.host_config))?; - } else { - write!(f, "manifest: {}", self.manifest_ref)?; - } - Ok(()) - } -} - -fn lifecycle_state_label(record: &DynamicPluginRecord) -> &'static str { - if record.is_tombstoned() { - "tombstoned" - } else { - record.status.runtime.state.into() - } -} - -fn host_config_label(host_config: Option<&ResolvedDynamicPluginConfig>) -> &'static str { - host_config - .map(|plugin| { - let status: &'static str = plugin.host_config_status().into(); - status - }) - .unwrap_or("absent") -} - -fn redacted_host_config_json(host_config: &ResolvedDynamicPluginConfig) -> Value { - if host_config.config.is_empty() && !host_config.has_explicit_config { - return Value::Null; - } - - Value::Object( - host_config - .config - .keys() - .cloned() - .map(|key| (key, Value::String("".into()))) - .collect(), - ) -} - -pub(super) fn inspect_load_data(record: &DynamicPluginRecord) -> Value { - match &record.load { - DynamicPluginLoadContract::Worker(load) => serde_json::json!({ - "runtime": load.runtime, - "entrypoint": load.entrypoint, - }), - DynamicPluginLoadContract::RustDynamic(load) => serde_json::json!({ - "library": load.library, - "symbol": load.symbol, - }), - } -} - -pub(super) fn inspect_compat_data(record: &DynamicPluginRecord) -> Value { - match &record.compatibility { - DynamicPluginCompatibility::Worker(compatibility) => serde_json::json!({ - "relay": compatibility.relay, - "worker_protocol": compatibility.worker_protocol, - }), - DynamicPluginCompatibility::RustDynamic(compatibility) => serde_json::json!({ - "relay": compatibility.relay, - "native_api": compatibility.native_api, - }), - } -} - -#[cfg(test)] -#[path = "../../tests/coverage/plugins_lifecycle_tests.rs"] -mod tests; diff --git a/crates/cli/src/plugins/lifecycle/environment.rs b/crates/cli/src/plugins/lifecycle/environment.rs index c66eacf0f..c8a0d572e 100644 --- a/crates/cli/src/plugins/lifecycle/environment.rs +++ b/crates/cli/src/plugins/lifecycle/environment.rs @@ -4,13 +4,29 @@ use std::ffi::{OsStr, OsString}; use std::path::{Path, PathBuf}; use std::process::Command; +#[cfg(test)] +use std::sync::atomic::{AtomicUsize, Ordering}; use nemo_relay::plugin::dynamic::{ DynamicPluginCheckState, DynamicPluginManifest, DynamicPluginManifestLoad, WorkerRuntime, }; +use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; -const MANAGED_ENVIRONMENTS_DIR: &str = ".dynamic-plugin-environments"; +pub(super) const MANAGED_ENVIRONMENTS_DIR: &str = ".dynamic-plugin-environments"; +pub(super) const ENVIRONMENT_ATTESTATION_FILE: &str = ".nemo-relay-environment.sha256"; +const MAX_ENVIRONMENT_FILES: usize = 100_000; +pub(super) const MAX_ENVIRONMENT_DEPTH: usize = 128; +#[cfg(test)] +static ENVIRONMENT_TREE_DIGEST_CALLS: AtomicUsize = AtomicUsize::new(0); + +#[derive(Deserialize, Serialize)] +struct EnvironmentAttestation { + version: u8, + source_artifact_sha256: String, + environment_sha256: String, + authentication: String, +} pub(super) trait PythonEnvironmentCommandRunner { fn run(&self, program: &OsStr, args: &[OsString]) -> Result<(), String>; @@ -42,6 +58,106 @@ pub(super) fn is_python_worker(manifest: &DynamicPluginManifest) -> bool { ) } +pub(super) fn validate_python_entrypoint_artifact( + manifest: &DynamicPluginManifest, + manifest_ref: &str, +) -> Result<(), String> { + let DynamicPluginManifestLoad::Worker(load) = &manifest.load else { + return Ok(()); + }; + if load.runtime != Some(WorkerRuntime::Python) { + return Ok(()); + } + + let source = manifest.source.as_ref().ok_or_else(|| { + "Python worker plugins must declare source.manifest_root and source.artifact".to_string() + })?; + let manifest_root = source + .manifest_root + .as_deref() + .map(str::trim) + .filter(|root| !root.is_empty()) + .ok_or_else(|| { + "Python worker plugins added through the CLI must declare source.manifest_root" + .to_string() + })?; + let artifact = source + .artifact + .as_deref() + .map(str::trim) + .filter(|artifact| !artifact.is_empty()) + .ok_or_else(|| "Python worker plugins must declare source.artifact".to_string())?; + let entrypoint = load + .entrypoint + .as_deref() + .map(str::trim) + .filter(|entrypoint| !entrypoint.is_empty()) + .ok_or_else(|| "Python worker plugins must declare load.entrypoint".to_string())?; + let (module, callable) = entrypoint.split_once(':').ok_or_else(|| { + format!( + "Python worker load.entrypoint '{entrypoint}' must use the unambiguous module:function form" + ) + })?; + if callable.is_empty() + || callable.contains(':') + || module.is_empty() + || module + .split('.') + .any(|segment| segment.is_empty() || segment.contains(['/', '\\', ':'])) + { + return Err(format!( + "Python worker load.entrypoint '{entrypoint}' must use the unambiguous module:function form" + )); + } + + let manifest_path = Path::new(manifest_ref); + let manifest_dir = manifest_path.parent().unwrap_or_else(|| Path::new(".")); + let unresolved_manifest_root = resolve_relative_path(manifest_dir, manifest_root); + let manifest_root = unresolved_manifest_root.canonicalize().map_err(|error| { + format!( + "could not resolve Python plugin source.manifest_root {}: {error}", + unresolved_manifest_root.display() + ) + })?; + let artifact = resolve_relative_path(manifest_dir, artifact) + .canonicalize() + .map_err(|error| format!("could not resolve Python source.artifact: {error}"))?; + let module_path = module + .split('.') + .fold(manifest_root.clone(), |path, segment| path.join(segment)); + let module_file = module_path.with_extension("py"); + let package_file = module_path.join("__init__.py"); + let mut candidates = [module_file, package_file] + .into_iter() + .filter(|path| path.is_file()) + .map(|path| { + path.canonicalize().map_err(|error| { + format!( + "could not resolve Python entrypoint module file {}: {error}", + path.display() + ) + }) + }) + .collect::, _>>()?; + candidates.sort(); + candidates.dedup(); + let [entrypoint_artifact] = candidates.as_slice() else { + return Err(format!( + "Python worker load.entrypoint '{entrypoint}' must resolve to exactly one source module under source.manifest_root; expected {} or {}", + module_path.with_extension("py").display(), + module_path.join("__init__.py").display() + )); + }; + if entrypoint_artifact != &artifact { + return Err(format!( + "Python worker load.entrypoint '{entrypoint}' resolves to {}, but integrity-checked source.artifact resolves to {}; the executed entrypoint module must be the integrity-checked artifact", + entrypoint_artifact.display(), + artifact.display() + )); + } + Ok(()) +} + pub(super) fn provision_python_environment( manifest: &DynamicPluginManifest, manifest_ref: &str, @@ -51,6 +167,7 @@ pub(super) fn provision_python_environment( if !is_python_worker(manifest) { return Ok(None); } + validate_python_entrypoint_artifact(manifest, manifest_ref)?; let manifest_root = manifest .source @@ -132,9 +249,264 @@ pub(super) fn provision_python_environment( )); } + let source_artifact_sha256 = manifest + .integrity + .as_ref() + .and_then(|integrity| integrity.sha256.as_deref()) + .map(str::trim) + .filter(|digest| !digest.is_empty()) + .ok_or_else(|| { + "Python worker plugins require integrity.sha256 to bind the installed environment to the trusted source artifact" + .to_string() + })?; + write_environment_attestation(&environment, source_artifact_sha256)?; + Ok(Some(environment)) } +pub(super) fn read_environment_attestation( + environment: &Path, + expected_source_artifact_sha256: &str, +) -> Result { + let attestation_path = environment.join(ENVIRONMENT_ATTESTATION_FILE); + let raw = std::fs::read_to_string(&attestation_path) + .map_err(|error| format!("failed to read {}: {error}", attestation_path.display()))?; + let attestation = serde_json::from_str::(&raw).map_err(|error| { + format!( + "managed Python environment attestation {} is invalid: {error}", + attestation_path.display() + ) + })?; + if attestation.version != 1 + || attestation.source_artifact_sha256 != expected_source_artifact_sha256.trim() + || attestation.environment_sha256.len() != 64 + || !attestation + .environment_sha256 + .bytes() + .all(|byte| byte.is_ascii_hexdigit()) + { + return Err(format!( + "managed Python environment attestation {} does not match the trusted source artifact", + attestation_path.display() + )); + } + if !crate::configuration::verify_python_environment_attestation( + &attestation.source_artifact_sha256, + &attestation.environment_sha256, + &attestation.authentication, + ) + .map_err(|error| error.to_string())? + { + return Err(format!( + "managed Python environment attestation {} failed authentication", + attestation_path.display() + )); + } + Ok(attestation.environment_sha256) +} + +pub(super) fn verify_environment_attestation( + environment: &Path, + expected_source_artifact_sha256: &str, +) -> Result { + let expected = read_environment_attestation(environment, expected_source_artifact_sha256)?; + let actual = environment_tree_digest(environment)?; + if actual != expected { + return Err(format!( + "managed Python environment {} changed after provisioning", + environment.display() + )); + } + Ok(actual) +} + +pub(super) fn write_environment_attestation( + environment: &Path, + source_artifact_sha256: &str, +) -> Result<(), String> { + let digest = environment_tree_digest(environment)?; + let path = environment.join(ENVIRONMENT_ATTESTATION_FILE); + let authentication = + crate::configuration::sign_python_environment_attestation(source_artifact_sha256, &digest) + .map_err(|error| error.to_string())?; + let mut bytes = serde_json::to_vec_pretty(&EnvironmentAttestation { + version: 1, + source_artifact_sha256: source_artifact_sha256.trim().to_owned(), + environment_sha256: digest, + authentication, + }) + .map_err(|error| format!("failed to encode {}: {error}", path.display()))?; + bytes.push(b'\n'); + std::fs::write(&path, bytes) + .map_err(|error| format!("failed to write {}: {error}", path.display())) +} + +pub(super) fn environment_tree_digest(environment: &Path) -> Result { + #[cfg(test)] + ENVIRONMENT_TREE_DIGEST_CALLS.fetch_add(1, Ordering::Relaxed); + environment_tree_digest_with_limit(environment, MAX_ENVIRONMENT_FILES) +} + +fn environment_tree_digest_with_limit( + environment: &Path, + max_entries: usize, +) -> Result { + let mut digest = Sha256::new(); + let mut total = 0_u64; + let mut entries = 0_usize; + digest_environment_directory( + environment, + Path::new(""), + &mut Vec::new(), + &mut digest, + &mut total, + &mut entries, + max_entries, + )?; + Ok(digest + .finalize() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect()) +} + +#[cfg(test)] +pub(super) fn test_environment_tree_digest_with_entry_limit( + environment: &Path, + max_entries: usize, +) -> Result { + environment_tree_digest_with_limit(environment, max_entries) +} + +#[cfg(test)] +pub(super) fn reset_environment_tree_digest_calls() { + ENVIRONMENT_TREE_DIGEST_CALLS.store(0, Ordering::Relaxed); +} + +#[cfg(test)] +pub(super) fn environment_tree_digest_calls() -> usize { + ENVIRONMENT_TREE_DIGEST_CALLS.load(Ordering::Relaxed) +} + +fn digest_environment_directory( + directory: &Path, + relative_directory: &Path, + ancestors: &mut Vec, + digest: &mut Sha256, + total: &mut u64, + entries: &mut usize, + max_entries: usize, +) -> Result<(), String> { + if ancestors.len() >= MAX_ENVIRONMENT_DEPTH { + return Err(format!( + "managed Python environment exceeds the {MAX_ENVIRONMENT_DEPTH}-directory traversal depth at {}", + directory.display() + )); + } + let canonical_directory = std::fs::canonicalize(directory) + .map_err(|error| format!("failed to normalize {}: {error}", directory.display()))?; + if ancestors.contains(&canonical_directory) { + return Err(format!( + "managed Python environment contains a directory symlink cycle at {}", + directory.display() + )); + } + ancestors.push(canonical_directory.clone()); + let mut children = Vec::new(); + for child in std::fs::read_dir(directory) + .map_err(|error| format!("failed to read {}: {error}", directory.display()))? + { + *entries = entries.saturating_add(1); + if *entries > max_entries { + return Err(format!( + "managed Python environment exceeds the {max_entries}-entry attestation budget at {}", + directory.display() + )); + } + children.push( + child.map_err(|error| format!("failed to read {}: {error}", directory.display()))?, + ); + } + children.sort_by_key(std::fs::DirEntry::file_name); + for child in children { + let path = child.path(); + let relative = relative_directory.join(child.file_name()); + if relative == Path::new(ENVIRONMENT_ATTESTATION_FILE) + || path.file_name().and_then(|name| name.to_str()) == Some("__pycache__") + || path.extension().and_then(|extension| extension.to_str()) == Some("pyc") + { + continue; + } + let metadata = std::fs::symlink_metadata(&path) + .map_err(|error| format!("failed to inspect {}: {error}", path.display()))?; + let source = if metadata.file_type().is_symlink() { + std::fs::canonicalize(&path) + .map_err(|error| format!("failed to resolve {}: {error}", path.display()))? + } else { + path.clone() + }; + let source_metadata = std::fs::metadata(&source) + .map_err(|error| format!("failed to inspect {}: {error}", source.display()))?; + if source_metadata.is_dir() { + update_tree_digest(digest, b'd', &relative, &[]); + digest_environment_directory( + &source, + &relative, + ancestors, + digest, + total, + entries, + max_entries, + )?; + continue; + } + if !source_metadata.is_file() { + return Err(format!( + "managed Python environment entry {} must resolve to a regular file or directory", + path.display() + )); + } + let bytes = crate::filesystem::bounded::read_bounded_regular_file( + &source, + "managed Python environment file", + )?; + *total = total.saturating_add(bytes.len() as u64); + if *total > crate::filesystem::bounded::MAX_BOUNDED_FILE_BYTES { + return Err(format!( + "managed Python environment exceeds the {}-byte attestation budget", + crate::filesystem::bounded::MAX_BOUNDED_FILE_BYTES + )); + } + update_tree_digest(digest, b'f', &relative, &bytes); + } + ancestors.pop(); + Ok(()) +} + +fn update_tree_digest(digest: &mut Sha256, entry_type: u8, path: &Path, payload: &[u8]) { + let path = raw_path_bytes(path); + digest.update([entry_type]); + digest.update((path.len() as u64).to_le_bytes()); + digest.update(&path); + digest.update((payload.len() as u64).to_le_bytes()); + digest.update(payload); +} + +#[cfg(unix)] +fn raw_path_bytes(path: &Path) -> Vec { + use std::os::unix::ffi::OsStrExt; + path.as_os_str().as_bytes().to_vec() +} + +#[cfg(windows)] +fn raw_path_bytes(path: &Path) -> Vec { + use std::os::windows::ffi::OsStrExt; + path.as_os_str() + .encode_wide() + .flat_map(u16::to_le_bytes) + .collect() +} + pub(super) fn remove_managed_environment( state_path: &Path, plugin_id: &str, @@ -174,6 +546,11 @@ pub(super) fn environment_state( .map(|metadata| !metadata.file_type().is_dir()) .unwrap_or(true) || !environment_python_path(&configured).is_file() + || manifest + .integrity + .as_ref() + .and_then(|integrity| integrity.sha256.as_deref()) + .is_none_or(|digest| verify_environment_attestation(&configured, digest).is_err()) { return DynamicPluginCheckState::Invalid; } diff --git a/crates/cli/src/plugins/lifecycle/mod.rs b/crates/cli/src/plugins/lifecycle/mod.rs new file mode 100644 index 000000000..af539db76 --- /dev/null +++ b/crates/cli/src/plugins/lifecycle/mod.rs @@ -0,0 +1,2256 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use std::collections::{BTreeMap, BTreeSet, HashMap}; +use std::fmt; +use std::fs; +use std::path::Path; +use std::path::PathBuf; +use std::process::ExitCode; +use std::sync::Arc; + +use nemo_relay::plugin::dynamic::{ + DynamicPluginCheckState, DynamicPluginCompatibility, DynamicPluginFailure, + DynamicPluginFailurePhase, DynamicPluginKind, DynamicPluginLoadContract, DynamicPluginManifest, + DynamicPluginManifestLoad, DynamicPluginRecord, DynamicPluginValidationStatus, WorkerRuntime, +}; +use serde_json::{Map, Value}; +use sha2::{Digest, Sha256}; + +use crate::configuration::{ + ResolvedConfig, ResolvedDynamicPluginConfig, load_bounded_dynamic_plugin_manifest_bytes, + resolve_plugins_config, +}; +use crate::error::{CliError, PluginLifecycleFailureKind}; +use crate::filesystem::bounded::{ + MAX_BOUNDED_FILE_BYTES as MAX_BOOTSTRAP_IDENTITY_FILE_BYTES, read_bounded_regular_file, +}; +use crate::plugins::policy::{ + EvaluatedDynamicPluginHostPolicy, evaluate_dynamic_plugin_host_policy, +}; +use crate::server::GatewayOverrides; + +use super::config_io::{ + append_dynamic_plugin_reference, remove_dynamic_plugin_reference, target_scope, +}; +use super::schema::PluginConfigSchema; +use super::{ + PluginsAddRequest, PluginsDisableRequest, PluginsEnableRequest, PluginsInspectRequest, + PluginsListRequest, PluginsRemoveRequest, PluginsValidateRequest, +}; + +mod environment; +mod render; +mod responses; +mod state; +mod target; +mod trust; + +use self::environment::{ + ENVIRONMENT_ATTESTATION_FILE, MANAGED_ENVIRONMENTS_DIR, ProcessPythonEnvironmentCommandRunner, + PythonEnvironmentCommandRunner, environment_state, provision_python_environment, + read_environment_attestation, remove_managed_environment, validate_python_entrypoint_artifact, + verify_environment_attestation, +}; +use self::render::*; +pub(crate) use self::render::{render_generic_plugin_json_error, render_plugin_error}; +use self::responses::{ + ValidateResponseInput, failure, generic_failure, inspect_data, inspect_success, list_success, + print_response_json, validate_success, +}; +use self::state::{ + RegistryScope, ScopedDynamicPluginRecord, ScopedRegistry, collect_records, find_record_by_id, + load_scoped_registries, scoped_paths_for_add, +}; +use self::target::PluginTarget; +use self::trust::{EvaluatedDynamicPluginTrust, evaluate_dynamic_plugin_trust}; + +const VALIDATION_MESSAGE: &str = "validated by CLI"; + +#[cfg(test)] +pub(crate) fn attest_test_python_environment( + environment: &Path, + source_artifact_sha256: &str, +) -> Result<(), String> { + self::environment::write_environment_attestation(environment, source_artifact_sha256) +} + +#[cfg(test)] +pub(crate) fn reset_test_python_environment_digest_calls() { + self::environment::reset_environment_tree_digest_calls(); +} + +#[cfg(test)] +pub(crate) fn test_python_environment_digest_calls() -> usize { + self::environment::environment_tree_digest_calls() +} + +pub(crate) fn add(command: PluginsAddRequest, server: &GatewayOverrides) -> Result<(), CliError> { + add_with_environment_runner(command, server, &ProcessPythonEnvironmentCommandRunner) +} + +fn add_with_environment_runner( + command: PluginsAddRequest, + server: &GatewayOverrides, + environment_runner: &impl PythonEnvironmentCommandRunner, +) -> Result<(), CliError> { + const COMMAND: &str = "plugins add"; + + let resolved = resolve_plugins_config(server.config.as_ref())?; + let mut scopes = load_and_hydrate_scopes(server.config.as_ref(), &resolved)?; + let (manifest, manifest_ref) = load_manifest_for_action("add", &command.path)?; + let plugin_id = manifest.plugin.id.trim().to_owned(); + load_config_schema_for_manifest(&manifest, &manifest_ref)?; + let revived = match find_record_by_id(&scopes, &plugin_id)? { + Some(existing) if !existing.record.is_tombstoned() => { + return Err(CliError::Config(format!( + "dynamic plugin '{}' is already registered in the {} lifecycle scope", + plugin_id, existing.scope + ))); + } + Some(_) => true, + None => false, + }; + + if server.config.is_some() && scope_flags_selected(&command.scope) { + return Err(CliError::Config( + "--config cannot be combined with --user, --project, or --global for `plugins add`" + .into(), + )); + } + + let (plugins_toml_path, state_path, scope) = + scoped_paths_for_add(target_scope(&command.scope)?, server.config.as_ref())?; + let scope_index = ensure_scope(&mut scopes, scope, plugins_toml_path.clone(), state_path); + let policy = evaluate_dynamic_plugin_host_policy(&resolved.dynamic_plugin_policy, &manifest); + let trust = evaluate_dynamic_plugin_trust(&manifest, &manifest_ref, &policy); + if !policy.policy_satisfied { + return Err(plugin_refused_with_code( + COMMAND, + Some(plugin_id.clone()), + "policy_blocked", + policy + .failure() + .map(|failure| failure.display(&plugin_id).to_string()) + .unwrap_or_else(|| { + format!("dynamic plugin '{}' is blocked by host policy", plugin_id) + }), + )); + } + if let Some(failure) = trust.failure() { + return Err(plugin_refused_with_code( + COMMAND, + Some(plugin_id.clone()), + trust_refusal_code(&trust), + failure.display(&plugin_id).to_string(), + )); + } + let environment_ref = provision_python_environment( + &manifest, + &manifest_ref, + &scopes[scope_index].state_path, + environment_runner, + ) + .map_err(|message| { + plugin_failed_with_code( + COMMAND, + Some(plugin_id.clone()), + "environment_failed", + message, + ) + })?; + let environment_ref_string = environment_ref + .as_ref() + .map(|environment| environment.display().to_string()); + let record = match validated_record_from_manifest( + manifest, + manifest_ref.clone(), + environment_ref_string, + &scopes[scope_index].state_path, + &policy, + &trust, + ) { + Ok(record) => record, + Err(error) => { + cleanup_provisioned_environment( + &scopes[scope_index].state_path, + &plugin_id, + environment_ref.as_deref(), + ); + return Err(error); + } + }; + let original_plugins_toml = std::fs::read(&plugins_toml_path).ok(); + + if let Err(error) = scopes[scope_index] + .registry + .add(record) + .map_err(|error| CliError::Config(error.to_string())) + { + cleanup_provisioned_environment( + &scopes[scope_index].state_path, + &plugin_id, + environment_ref.as_deref(), + ); + return Err(error); + } + if let Err(error) = append_dynamic_plugin_reference(&plugins_toml_path, &manifest_ref) { + cleanup_provisioned_environment( + &scopes[scope_index].state_path, + &plugin_id, + environment_ref.as_deref(), + ); + return Err(error); + } + if let Err(error) = scopes[scope_index].save() { + let _ = restore_plugins_toml(&plugins_toml_path, original_plugins_toml.as_deref()); + cleanup_provisioned_environment( + &scopes[scope_index].state_path, + &plugin_id, + environment_ref.as_deref(), + ); + return Err(error); + } + + println!( + "{} dynamic plugin {}", + if revived { "Revived" } else { "Added" }, + plugin_id + ); + Ok(()) +} + +fn cleanup_provisioned_environment(state_path: &Path, plugin_id: &str, environment: Option<&Path>) { + if let Some(environment) = environment { + let _ = remove_managed_environment( + state_path, + plugin_id, + environment.to_string_lossy().as_ref(), + ); + } +} + +pub(crate) fn enforce_required_dynamic_plugin_startup( + explicit: Option<&PathBuf>, + resolved: &ResolvedConfig, +) -> Result<(), CliError> { + let (scopes, touched_scope_indices) = load_and_hydrate_scopes_with_updates(explicit, resolved)?; + for scope_index in touched_scope_indices { + scopes[scope_index].save()?; + } + let required_failures = collect_records(&scopes, false) + .into_iter() + .filter(|entry| entry.record.spec.enabled) + .filter_map(|entry| required_startup_failure(&entry, resolved.dynamic_plugins.as_slice())) + .collect::>(); + + if required_failures.is_empty() { + return Ok(()); + } + + Err(CliError::Config(format!( + "required dynamic plugin startup preflight failed:\n{}", + required_failures.join("\n") + ))) +} + +pub(crate) fn validate( + command: PluginsValidateRequest, + server: &GatewayOverrides, +) -> Result<(), CliError> { + match PluginTarget::parse(&command.target) { + PluginTarget::Path(path) => { + if !path.exists() { + return Err(plugin_not_found( + "plugins validate", + Some(command.target.clone()), + format!("dynamic plugin target '{}' does not exist", command.target), + )); + } + let resolved = resolve_plugins_config(server.config.as_ref())?; + let (manifest, manifest_ref) = load_manifest_for_action("validate", &path)?; + validate_python_entrypoint_artifact(&manifest, &manifest_ref) + .map_err(CliError::Config)?; + load_config_schema_for_manifest(&manifest, &manifest_ref)?; + let policy = + evaluate_dynamic_plugin_host_policy(&resolved.dynamic_plugin_policy, &manifest); + let trust = evaluate_dynamic_plugin_trust(&manifest, &manifest_ref, &policy); + if command.json { + print_response_json(&validate_success(ValidateResponseInput { + command: "plugins validate", + target: Some(command.target.as_str()), + target_kind: "path", + resolved_plugin_id: Some(manifest.plugin.id.as_str()), + manifest: &manifest, + manifest_ref: &manifest_ref, + entry: None, + host_config: None, + policy: &policy, + trust: &trust, + }))?; + } else { + println!( + "{}", + PluginValidationSummaryView { + manifest: &manifest, + manifest_ref: &manifest_ref, + entry: None, + host_config: None, + policy: &policy, + trust: &trust, + } + ); + } + Ok(()) + } + PluginTarget::Id(plugin_id) => { + let resolved = resolve_plugins_config(server.config.as_ref())?; + let host_config_by_id = host_config_by_id(&resolved); + let mut scopes = load_and_hydrate_scopes(server.config.as_ref(), &resolved)?; + let entry = find_registered_entry(&scopes, "plugins validate", &plugin_id)?; + let manifest_ref = manifest_ref_from_record(&entry.record)?; + let (manifest, manifest_ref) = load_manifest_for_action("validate", &manifest_ref)?; + validate_python_entrypoint_artifact(&manifest, &manifest_ref) + .map_err(CliError::Config)?; + if let Some(schema) = load_config_schema_for_manifest(&manifest, &manifest_ref)? { + let config = host_config_by_id + .get(&plugin_id) + .map(|host_config| Value::Object(host_config.config.clone())) + .unwrap_or_else(|| Value::Object(Map::new())); + schema.validate(&config)?; + } + let policy = + evaluate_dynamic_plugin_host_policy(&resolved.dynamic_plugin_policy, &manifest); + let trust = evaluate_dynamic_plugin_trust(&manifest, &manifest_ref, &policy); + update_registry_validation_status( + &mut scopes[entry.scope_index], + &plugin_id, + &manifest, + &policy, + &trust, + )?; + scopes[entry.scope_index].save()?; + let refreshed = find_record_by_id(&scopes, &plugin_id)? + .expect("validated registry record should still exist"); + if command.json { + print_response_json(&validate_success(ValidateResponseInput { + command: "plugins validate", + target: Some(plugin_id.as_str()), + target_kind: "plugin_id", + resolved_plugin_id: Some(plugin_id.as_str()), + manifest: &manifest, + manifest_ref: &manifest_ref, + entry: Some(&refreshed), + host_config: host_config_by_id.get(&plugin_id), + policy: &policy, + trust: &trust, + }))?; + } else { + println!( + "{}", + PluginValidationSummaryView { + manifest: &manifest, + manifest_ref: &manifest_ref, + entry: Some(&refreshed), + host_config: host_config_by_id.get(&plugin_id), + policy: &policy, + trust: &trust, + } + ); + } + Ok(()) + } + } +} + +pub(crate) fn list(command: PluginsListRequest, server: &GatewayOverrides) -> Result<(), CliError> { + let resolved = resolve_plugins_config(server.config.as_ref())?; + let host_config_by_id = host_config_by_id(&resolved); + let scopes = load_and_hydrate_scopes(server.config.as_ref(), &resolved)?; + let records = collect_records(&scopes, command.all); + if records.is_empty() { + if command.json { + print_response_json(&list_success( + "plugins list", + None, + &records, + &host_config_by_id, + ))?; + } else { + println!("No dynamic plugins registered."); + } + return Ok(()); + } + if command.json { + print_response_json(&list_success( + "plugins list", + None, + &records, + &host_config_by_id, + ))?; + } else { + println!( + "{}", + PluginListView { + records: &records, + host_config_by_id: &host_config_by_id, + } + ); + } + Ok(()) +} + +pub(crate) fn inspect( + command: PluginsInspectRequest, + server: &GatewayOverrides, +) -> Result<(), CliError> { + let resolved = resolve_plugins_config(server.config.as_ref())?; + let host_config_by_id = host_config_by_id(&resolved); + let scopes = load_and_hydrate_scopes(server.config.as_ref(), &resolved)?; + let entry = find_registered_entry(&scopes, "plugins inspect", &command.id)?; + let manifest_ref = manifest_ref_from_record(&entry.record)?; + let (manifest, manifest_ref) = load_manifest_for_action("inspect", &manifest_ref)?; + if command.json { + print_response_json(&inspect_success( + "plugins inspect", + command.id.as_str(), + &entry, + &manifest, + &manifest_ref, + host_config_by_id.get(&command.id), + ))?; + } else { + println!( + "{}", + PluginInspectView { + entry: &entry, + manifest: &manifest, + manifest_ref: &manifest_ref, + host_config: host_config_by_id.get(&command.id), + } + ); + } + Ok(()) +} + +pub(crate) fn enable( + command: PluginsEnableRequest, + server: &GatewayOverrides, +) -> Result<(), CliError> { + mutate_enabled_state(command.id, server, true) +} + +pub(crate) fn disable( + command: PluginsDisableRequest, + server: &GatewayOverrides, +) -> Result<(), CliError> { + mutate_enabled_state(command.id, server, false) +} + +pub(crate) fn remove( + command: PluginsRemoveRequest, + server: &GatewayOverrides, +) -> Result<(), CliError> { + let mut scopes = load_scoped_registries(server.config.as_ref())?; + if find_record_by_id(&scopes, &command.id)?.is_none() { + let resolved = resolve_plugins_config(server.config.as_ref())?; + scopes = load_and_hydrate_scopes(server.config.as_ref(), &resolved)?; + } + let entry = find_registered_entry(&scopes, "plugins remove", &command.id)?; + let original_plugins_toml = std::fs::read(&entry.plugins_toml_path).ok(); + let environment_ref = entry.record.source.environment_ref.clone(); + + scopes[entry.scope_index] + .registry + .remove(&command.id) + .map_err(|error| CliError::Config(error.to_string()))?; + remove_dynamic_plugin_reference( + &entry.plugins_toml_path, + &command.id, + entry.record.source.manifest_ref.as_deref(), + )?; + if let Err(error) = scopes[entry.scope_index].save() { + let _ = restore_plugins_toml(&entry.plugins_toml_path, original_plugins_toml.as_deref()); + return Err(error); + } + + if let Some(environment_ref) = environment_ref { + remove_managed_environment(&entry.state_path, &command.id, &environment_ref) + .map_err(CliError::Config)?; + scopes[entry.scope_index] + .registry + .update_environment(&command.id, None, DynamicPluginCheckState::Unknown) + .map_err(|error| CliError::Config(error.to_string()))?; + scopes[entry.scope_index].save()?; + } + + println!("Removed dynamic plugin {}", command.id); + Ok(()) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ActiveDynamicPluginComponent { + pub(crate) plugin_id: String, + pub(crate) kind: DynamicPluginKind, + pub(crate) lifecycle_generation: u64, + pub(crate) manifest_ref: Option, + pub(crate) environment_ref: Option, + pub(crate) config: Map, + pub(crate) activation_snapshot: Option>, +} + +#[derive(Debug, PartialEq, Eq)] +pub(crate) struct DynamicPluginActivationSnapshot { + root: PathBuf, + original_manifest_ref: String, + identity_manifest: PathBuf, + activation_manifest: PathBuf, + activation_environment_ref: Option, + identity_files: HashMap, + closure_digest: String, + verification_digest: String, +} + +impl DynamicPluginActivationSnapshot { + fn create( + manifest_ref: &str, + expected_plugin_id: &str, + expected_kind: DynamicPluginKind, + environment_ref: Option<&str>, + host_policy: &crate::plugins::policy::DynamicPluginHostPolicy, + ) -> Result, CliError> { + let (mut manifest, original_manifest_ref, manifest_bytes) = + load_bounded_dynamic_plugin_manifest_bytes(manifest_ref)?; + if manifest.plugin.id.trim() != expected_plugin_id || manifest.plugin.kind != expected_kind + { + return Err(CliError::Config(format!( + "dynamic plugin manifest identity changed before activation for '{expected_plugin_id}'" + ))); + } + let policy = evaluate_dynamic_plugin_host_policy(host_policy, &manifest); + validate_python_entrypoint_artifact(&manifest, &original_manifest_ref) + .map_err(CliError::Config)?; + + let root = std::env::temp_dir().join(format!( + "nemo-relay-plugin-snapshot-{}", + uuid::Uuid::now_v7().simple() + )); + fs::create_dir(&root).map_err(|error| { + CliError::Config(format!( + "failed to create dynamic plugin activation snapshot {}: {error}", + root.display() + )) + })?; + let mut root_guard = SnapshotRootGuard(Some(root.clone())); + #[cfg(unix)] + fs::set_permissions(&root, { + use std::os::unix::fs::PermissionsExt; + fs::Permissions::from_mode(0o700) + }) + .map_err(|error| { + CliError::Config(format!( + "failed to protect dynamic plugin activation snapshot {}: {error}", + root.display() + )) + })?; + + let identity_manifest = root.join("identity-manifest.toml"); + fs::write(&identity_manifest, &manifest_bytes).map_err(|error| { + CliError::Config(format!( + "failed to write dynamic plugin activation snapshot {}: {error}", + identity_manifest.display() + )) + })?; + let original_manifest_path = PathBuf::from(&original_manifest_ref); + let manifest_directory = original_manifest_path + .parent() + .ok_or_else(|| { + CliError::Config(format!( + "dynamic plugin manifest {} has no parent directory", + original_manifest_path.display() + )) + })? + .to_path_buf(); + let runtime_root = root.join("runtime"); + let mut budget = SnapshotBudget::default(); + let mut copied_files = HashMap::new(); + copy_snapshot_directory( + &manifest_directory, + &runtime_root, + &mut copied_files, + &mut budget, + false, + &mut Vec::new(), + )?; + let declared_artifact = manifest + .source + .as_ref() + .and_then(|source| source.artifact.as_deref()) + .map(|artifact| fs::canonicalize(resolve_manifest_relative_path(&original_manifest_path, artifact))) + .transpose() + .map_err(|error| { + CliError::Config(format!( + "failed to normalize dynamic plugin artifact for '{expected_plugin_id}': {error}" + )) + })?; + let mut identity_files = HashMap::new(); + + match &mut manifest.load { + DynamicPluginManifestLoad::RustDynamic(load) => { + if let Some(library) = load.library.as_deref() { + let (logical, _, copied) = copy_snapshot_file( + &root, + &original_manifest_path, + library, + "library", + &mut copied_files, + &mut budget, + )?; + identity_files + .entry(logical) + .or_insert_with(|| copied.clone()); + load.library = Some(copied.to_string_lossy().into_owned()); + } + } + DynamicPluginManifestLoad::Worker(load) + if matches!( + load.runtime, + Some(WorkerRuntime::Rust | WorkerRuntime::Command) + ) => + { + if let Some(entrypoint) = load.entrypoint.as_deref() { + let (logical, canonical, copied) = copy_snapshot_file( + &root, + &original_manifest_path, + entrypoint, + "entrypoint", + &mut copied_files, + &mut budget, + )?; + if declared_artifact.as_ref() != Some(&canonical) { + return Err(CliError::Config(format!( + "command worker dynamic plugin '{expected_plugin_id}' must declare its load.entrypoint as the integrity-checked source.artifact" + ))); + } + identity_files + .entry(logical) + .or_insert_with(|| copied.clone()); + load.entrypoint = Some(copied.to_string_lossy().into_owned()); + } + } + DynamicPluginManifestLoad::Worker(_) => {} + } + + if let Some(source) = manifest.source.as_mut() + && let Some(artifact) = source.artifact.as_deref() + { + let (logical, _, copied) = copy_snapshot_file( + &root, + &original_manifest_path, + artifact, + "artifact", + &mut copied_files, + &mut budget, + )?; + identity_files.insert(logical, copied.clone()); + source.artifact = Some(copied.to_string_lossy().into_owned()); + } + if let Some(integrity) = manifest.integrity.as_mut() + && let Some(signature) = integrity.signature.as_deref() + { + let (logical, _, copied) = copy_snapshot_file( + &root, + &original_manifest_path, + signature, + "signature", + &mut copied_files, + &mut budget, + )?; + identity_files.insert(logical, copied.clone()); + integrity.signature = Some(copied.to_string_lossy().into_owned()); + } + + let activation_environment_ref = if matches!( + &manifest.load, + DynamicPluginManifestLoad::Worker(load) + if load.runtime == Some(WorkerRuntime::Python) + ) { + let environment = environment_ref.ok_or_else(|| { + CliError::Config(format!( + "Python worker dynamic plugin '{expected_plugin_id}' has no managed environment" + )) + })?; + let source_artifact_sha256 = trusted_source_artifact_sha256(&manifest)?; + let environment = PathBuf::from(environment); + verify_environment_attestation(&environment, source_artifact_sha256) + .map_err(CliError::Config)?; + let environment_name = environment.file_name().ok_or_else(|| { + CliError::Config(format!( + "managed Python environment {} has no lifecycle environment name", + environment.display() + )) + })?; + let copied_environment = root.join(MANAGED_ENVIRONMENTS_DIR).join(environment_name); + copy_snapshot_directory( + &environment, + &copied_environment, + &mut copied_files, + &mut budget, + true, + &mut Vec::new(), + )?; + verify_environment_attestation(&copied_environment, source_artifact_sha256) + .map_err(CliError::Config)?; + Some(copied_environment.to_string_lossy().into_owned()) + } else { + None + }; + + let activation_manifest = runtime_root.join("relay-plugin.toml"); + let rendered = toml::to_string(&manifest).map_err(|error| { + CliError::Config(format!( + "failed to encode dynamic plugin activation snapshot for '{expected_plugin_id}': {error}" + )) + })?; + if rendered.len() as u64 > MAX_BOOTSTRAP_IDENTITY_FILE_BYTES { + return Err(CliError::Config(format!( + "dynamic plugin activation manifest for '{expected_plugin_id}' exceeds the {MAX_BOOTSTRAP_IDENTITY_FILE_BYTES}-byte activation snapshot budget" + ))); + } + fs::write(&activation_manifest, rendered).map_err(|error| { + CliError::Config(format!( + "failed to write dynamic plugin activation manifest {}: {error}", + activation_manifest.display() + )) + })?; + + let trust = evaluate_dynamic_plugin_trust( + &manifest, + activation_manifest.to_string_lossy().as_ref(), + &policy, + ); + if !policy.policy_satisfied { + return Err(CliError::Config(format!( + "dynamic plugin '{expected_plugin_id}' activation snapshot violates host policy" + ))); + } + if let Some(failure) = trust.failure() { + return Err(CliError::Config( + failure.display(expected_plugin_id).to_string(), + )); + } + + let closure_digest = snapshot_tree_digest(&root, true)?; + let verification_digest = snapshot_tree_digest(&root, false)?; + #[cfg(unix)] + protect_snapshot_tree(&root)?; + #[cfg(windows)] + protect_snapshot_tree(&root)?; + root_guard.0 = None; + Ok(Arc::new(Self { + root, + original_manifest_ref, + identity_manifest, + activation_manifest, + activation_environment_ref, + identity_files, + closure_digest, + verification_digest, + })) + } + + pub(crate) fn activation_manifest_ref(&self) -> String { + self.activation_manifest.to_string_lossy().into_owned() + } + + pub(crate) fn activation_environment_ref(&self) -> Option<&str> { + self.activation_environment_ref.as_deref() + } + + pub(crate) fn closure_digest(&self) -> &str { + &self.closure_digest + } + + pub(crate) fn verify_current(&self) -> Result<(), CliError> { + let actual = snapshot_tree_digest(&self.root, false)?; + if actual == self.verification_digest { + Ok(()) + } else { + Err(CliError::Config(format!( + "dynamic plugin activation snapshot {} changed before code load", + self.root.display() + ))) + } + } + + pub(crate) fn original_manifest_ref(&self) -> &str { + &self.original_manifest_ref + } + + pub(crate) fn identity_manifest(&self) -> &Path { + &self.identity_manifest + } + + pub(crate) fn identity_file(&self, logical_path: &Path) -> Option<&Path> { + self.identity_files.get(logical_path).map(PathBuf::as_path) + } +} + +struct SnapshotRootGuard(Option); + +impl Drop for SnapshotRootGuard { + fn drop(&mut self) { + if let Some(root) = self.0.take() { + make_snapshot_removable(&root); + let _ = fs::remove_dir_all(root); + } + } +} + +impl Drop for DynamicPluginActivationSnapshot { + fn drop(&mut self) { + make_snapshot_removable(&self.root); + let _ = fs::remove_dir_all(&self.root); + } +} + +fn copy_snapshot_file( + root: &Path, + manifest_path: &Path, + reference: &str, + label: &str, + copied_files: &mut HashMap, + budget: &mut SnapshotBudget, +) -> Result<(PathBuf, PathBuf, PathBuf), CliError> { + let logical = resolve_manifest_relative_path(manifest_path, reference); + let canonical = fs::canonicalize(&logical).map_err(|error| { + CliError::Config(format!( + "failed to normalize dynamic plugin {label} {}: {error}", + logical.display() + )) + })?; + if let Some(copied) = copied_files.get(&canonical) + && !matches!(label, "library" | "entrypoint") + { + return Ok((logical, canonical, copied.clone())); + } + if matches!(label, "library" | "entrypoint") { + let manifest_directory = manifest_path + .parent() + .and_then(|parent| fs::canonicalize(parent).ok()); + if manifest_directory + .as_ref() + .is_some_and(|directory| canonical.starts_with(directory)) + && let Some(copied) = copied_files.get(&canonical) + { + // The manifest directory is copied as a complete closure before declared paths are + // rewritten, so in-tree load targets already retain adjacent resources. + return Ok((logical, canonical, copied.clone())); + } + } + let external = root.join(format!("external-{label}")); + if matches!(label, "library" | "entrypoint") { + let parent = canonical.parent().ok_or_else(|| { + CliError::Config(format!( + "dynamic plugin {label} {} has no parent directory", + canonical.display() + )) + })?; + copy_snapshot_directory( + parent, + &external, + copied_files, + budget, + false, + &mut Vec::new(), + )?; + } else { + fs::create_dir_all(&external).map_err(|error| CliError::Config(error.to_string()))?; + let destination = external.join(canonical.file_name().unwrap_or_default()); + copy_snapshot_regular_file(&canonical, &destination, copied_files, budget, label)?; + } + let copied = copied_files.get(&canonical).cloned().ok_or_else(|| { + CliError::Config(format!( + "dynamic plugin {label} {} was not included in its activation snapshot", + canonical.display() + )) + })?; + Ok((logical, canonical, copied)) +} + +const MAX_SNAPSHOT_FILES: usize = 100_000; +const MAX_SNAPSHOT_DEPTH: usize = 128; + +#[derive(Default)] +struct SnapshotBudget { + entries: usize, + bytes: u64, +} + +impl SnapshotBudget { + fn record(&mut self, path: &Path, bytes: usize) -> Result<(), CliError> { + self.record_entries(path, 1)?; + self.record_bytes(path, bytes) + } + + fn record_entries(&mut self, path: &Path, count: usize) -> Result<(), CliError> { + self.entries = self.entries.saturating_add(count); + if self.entries > MAX_SNAPSHOT_FILES { + return Err(CliError::Config(format!( + "dynamic plugin runtime closure exceeds the {MAX_SNAPSHOT_FILES}-entry activation snapshot budget at {}", + path.display() + ))); + } + Ok(()) + } + + fn record_bytes(&mut self, path: &Path, bytes: usize) -> Result<(), CliError> { + self.bytes = self.bytes.saturating_add(bytes as u64); + if self.bytes > MAX_BOOTSTRAP_IDENTITY_FILE_BYTES { + return Err(CliError::Config(format!( + "dynamic plugin runtime closure exceeds the {MAX_BOOTSTRAP_IDENTITY_FILE_BYTES}-byte activation snapshot budget at {}", + path.display() + ))); + } + Ok(()) + } + + fn record_directory(&mut self, path: &Path) -> Result<(), CliError> { + self.record_entries(path, 1) + } +} + +fn copy_snapshot_directory( + source: &Path, + destination: &Path, + copied_files: &mut HashMap, + budget: &mut SnapshotBudget, + skip_python_cache: bool, + ancestors: &mut Vec, +) -> Result<(), CliError> { + budget.record_directory(source)?; + copy_snapshot_directory_contents( + source, + destination, + copied_files, + budget, + skip_python_cache, + ancestors, + ) +} + +fn copy_snapshot_directory_contents( + source: &Path, + destination: &Path, + copied_files: &mut HashMap, + budget: &mut SnapshotBudget, + skip_python_cache: bool, + ancestors: &mut Vec, +) -> Result<(), CliError> { + if ancestors.len() >= MAX_SNAPSHOT_DEPTH { + return Err(CliError::Config(format!( + "dynamic plugin runtime closure exceeds the {MAX_SNAPSHOT_DEPTH}-directory traversal depth at {}", + source.display() + ))); + } + let canonical = fs::canonicalize(source).map_err(|error| { + CliError::Config(format!( + "failed to normalize dynamic plugin runtime directory {}: {error}", + source.display() + )) + })?; + if ancestors.contains(&canonical) { + return Err(CliError::Config(format!( + "dynamic plugin runtime closure contains a directory symlink cycle at {}", + source.display() + ))); + } + ancestors.push(canonical.clone()); + fs::create_dir_all(destination).map_err(|error| { + CliError::Config(format!( + "failed to create dynamic plugin snapshot directory {}: {error}", + destination.display() + )) + })?; + let mut entries = bounded_runtime_directory_entries( + &canonical, + MAX_SNAPSHOT_FILES.saturating_sub(budget.entries), + )?; + budget.record_entries(source, entries.len())?; + entries.sort_by_key(fs::DirEntry::file_name); + for entry in entries { + let source_path = entry.path(); + if skip_python_cache + && (entry.file_name() == "__pycache__" + || source_path.extension().and_then(|value| value.to_str()) == Some("pyc")) + { + continue; + } + let destination_path = destination.join(entry.file_name()); + let metadata = fs::symlink_metadata(&source_path) + .map_err(|error| CliError::Config(error.to_string()))?; + let resolved = if metadata.file_type().is_symlink() { + fs::canonicalize(&source_path).map_err(|error| { + CliError::Config(format!( + "failed to resolve dynamic plugin runtime symlink {}: {error}", + source_path.display() + )) + })? + } else { + source_path.clone() + }; + let resolved_metadata = + fs::metadata(&resolved).map_err(|error| CliError::Config(error.to_string()))?; + if resolved_metadata.is_dir() { + copy_snapshot_directory_contents( + &resolved, + &destination_path, + copied_files, + budget, + skip_python_cache, + ancestors, + )?; + } else if resolved_metadata.is_file() { + // A macOS venv's `bin/python` is normally an absolute symlink to the managed + // interpreter. Dereferencing that link while snapshotting turns the interpreter + // into a standalone file whose @rpath no longer points at libpython, so the worker + // exits before it can create its socket. Preserve only these launcher links; all + // other runtime symlinks remain dereferenced to keep the activation snapshot + // self-contained and deterministic. + #[cfg(unix)] + if metadata.file_type().is_symlink() && is_python_venv_launcher(&source_path) { + let target = fs::read_link(&source_path).map_err(|error| { + CliError::Config(format!( + "failed to read Python venv launcher symlink {}: {error}", + source_path.display() + )) + })?; + if let Some(parent) = destination_path.parent() { + fs::create_dir_all(parent) + .map_err(|error| CliError::Config(error.to_string()))?; + } + std::os::unix::fs::symlink(&target, &destination_path).map_err(|error| { + CliError::Config(format!( + "failed to preserve Python venv launcher symlink {}: {error}", + destination_path.display() + )) + })?; + copied_files.insert(resolved, destination_path); + continue; + } + copy_snapshot_regular_file( + &resolved, + &destination_path, + copied_files, + budget, + "runtime file", + )?; + } else { + return Err(CliError::Config(format!( + "dynamic plugin runtime entry {} must resolve to a regular file or directory", + source_path.display() + ))); + } + } + ancestors.pop(); + Ok(()) +} + +#[cfg(unix)] +fn is_python_venv_launcher(path: &Path) -> bool { + let Some(parent) = path.parent() else { + return false; + }; + parent.file_name() == Some(std::ffi::OsStr::new("bin")) + && path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name == "python" || name.starts_with("python3")) +} + +fn copy_snapshot_regular_file( + source: &Path, + destination: &Path, + copied_files: &mut HashMap, + budget: &mut SnapshotBudget, + description: &str, +) -> Result<(), CliError> { + let bytes = read_bounded_regular_file(source, &format!("dynamic plugin {description}")) + .map_err(CliError::Config)?; + budget.record_bytes(source, bytes.len())?; + fs::write(destination, bytes).map_err(|error| { + CliError::Config(format!( + "failed to write dynamic plugin snapshot file {}: {error}", + destination.display() + )) + })?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = fs::metadata(source) + .map_err(|error| CliError::Config(error.to_string()))? + .permissions() + .mode(); + fs::set_permissions(destination, fs::Permissions::from_mode(mode)) + .map_err(|error| CliError::Config(error.to_string()))?; + } + copied_files.insert(source.to_path_buf(), destination.to_path_buf()); + Ok(()) +} + +fn resolve_manifest_relative_path(manifest_path: &Path, reference: &str) -> PathBuf { + let path = PathBuf::from(reference); + if path.is_absolute() { + path + } else { + manifest_path + .parent() + .map(|parent| parent.join(&path)) + .unwrap_or(path) + } +} + +#[cfg(unix)] +fn protect_snapshot_tree(root: &Path) -> Result<(), CliError> { + use std::os::unix::fs::PermissionsExt; + for entry in fs::read_dir(root).map_err(|error| CliError::Config(error.to_string()))? { + let path = entry + .map_err(|error| CliError::Config(error.to_string()))? + .path(); + let metadata = + fs::symlink_metadata(&path).map_err(|error| CliError::Config(error.to_string()))?; + if metadata.is_dir() { + protect_snapshot_tree(&path)?; + continue; + } + if metadata.file_type().is_symlink() { + continue; + } + let mode = metadata.permissions().mode() & !0o222; + fs::set_permissions(&path, fs::Permissions::from_mode(mode)) + .map_err(|error| CliError::Config(error.to_string()))?; + } + fs::set_permissions(root, fs::Permissions::from_mode(0o500)) + .map_err(|error| CliError::Config(error.to_string())) +} + +#[cfg(windows)] +fn protect_snapshot_tree(root: &Path) -> Result<(), CliError> { + for entry in fs::read_dir(root).map_err(|error| CliError::Config(error.to_string()))? { + let path = entry + .map_err(|error| CliError::Config(error.to_string()))? + .path(); + let metadata = + fs::symlink_metadata(&path).map_err(|error| CliError::Config(error.to_string()))?; + if metadata.is_dir() { + protect_snapshot_tree(&path)?; + } else if !metadata.file_type().is_symlink() { + let mut permissions = metadata.permissions(); + permissions.set_readonly(true); + fs::set_permissions(&path, permissions) + .map_err(|error| CliError::Config(error.to_string()))?; + } + } + Ok(()) +} + +fn snapshot_tree_digest(root: &Path, stable_identity: bool) -> Result { + let mut files = Vec::new(); + let mut entries = 0_usize; + collect_snapshot_files(root, root, &mut files, None, &mut entries)?; + files.sort(); + let mut digest = Sha256::new(); + let mut budget = SnapshotBudget::default(); + for relative in files { + if stable_identity { + let activation_manifest = Path::new("runtime").join("relay-plugin.toml"); + let is_python_environment_content = relative.starts_with(MANAGED_ENVIRONMENTS_DIR) + && relative.file_name() != Some(std::ffi::OsStr::new(ENVIRONMENT_ATTESTATION_FILE)); + if relative == activation_manifest || is_python_environment_content { + continue; + } + } + let path = root.join(&relative); + let metadata = fs::symlink_metadata(&path).map_err(|error| { + CliError::Config(format!( + "failed to inspect dynamic plugin activation snapshot entry {}: {error}", + path.display() + )) + })?; + if metadata.file_type().is_symlink() { + let target = fs::read_link(&path).map_err(|error| { + CliError::Config(format!( + "failed to read dynamic plugin activation snapshot symlink {}: {error}", + path.display() + )) + })?; + let target = target.as_os_str().as_encoded_bytes(); + budget.record(&path, target.len())?; + update_snapshot_entry_digest( + &mut digest, + &relative, + SnapshotEntryKind::Symlink, + target, + ); + } else { + let bytes = read_bounded_regular_file(&path, "dynamic plugin activation snapshot file") + .map_err(CliError::Config)?; + budget.record(&path, bytes.len())?; + update_snapshot_entry_digest(&mut digest, &relative, SnapshotEntryKind::File, &bytes); + } + } + Ok(digest + .finalize() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect()) +} + +pub(crate) fn dynamic_plugin_runtime_closure_digest( + manifest_ref: &str, + environment_ref: Option<&str>, +) -> Result { + let (manifest, normalized_manifest_ref, manifest_bytes) = + load_bounded_dynamic_plugin_manifest_bytes(manifest_ref)?; + let manifest_path = PathBuf::from(normalized_manifest_ref); + let manifest_directory = manifest_path.parent().ok_or_else(|| { + CliError::Config(format!( + "dynamic plugin manifest {} has no parent directory", + manifest_path.display() + )) + })?; + let mut closure = RuntimeClosureSources::default(); + closure.record_bytes(PathBuf::from("identity-manifest.toml"), manifest_bytes)?; + collect_runtime_closure_directory( + manifest_directory, + Path::new("runtime"), + false, + &mut Vec::new(), + &mut closure, + )?; + + let declared_artifact = manifest + .source + .as_ref() + .and_then(|source| source.artifact.as_deref()) + .map(|artifact| fs::canonicalize(resolve_manifest_relative_path(&manifest_path, artifact))) + .transpose() + .map_err(|error| { + CliError::Config(format!( + "failed to normalize dynamic plugin artifact for '{}': {error}", + manifest.plugin.id + )) + })?; + match &manifest.load { + DynamicPluginManifestLoad::RustDynamic(load) => { + if let Some(library) = load.library.as_deref() { + collect_declared_runtime_closure_file( + &manifest_path, + library, + "library", + &mut closure, + )?; + } + } + DynamicPluginManifestLoad::Worker(load) + if matches!( + load.runtime, + Some(WorkerRuntime::Rust | WorkerRuntime::Command) + ) => + { + if let Some(entrypoint) = load.entrypoint.as_deref() { + let canonical_entrypoint = + fs::canonicalize(resolve_manifest_relative_path(&manifest_path, entrypoint)) + .map_err(|error| { + CliError::Config(format!( + "failed to normalize dynamic plugin entrypoint for '{}': {error}", + manifest.plugin.id + )) + })?; + if declared_artifact.as_ref() != Some(&canonical_entrypoint) { + return Err(CliError::Config(format!( + "command worker dynamic plugin '{}' must declare its load.entrypoint as the integrity-checked source.artifact", + manifest.plugin.id + ))); + } + collect_declared_runtime_closure_file( + &manifest_path, + entrypoint, + "entrypoint", + &mut closure, + )?; + } + } + DynamicPluginManifestLoad::Worker(load) if load.runtime == Some(WorkerRuntime::Python) => { + let environment_ref = environment_ref.ok_or_else(|| { + CliError::Config(format!( + "Python worker dynamic plugin '{}' has no managed environment", + manifest.plugin.id + )) + })?; + let environment = Path::new(environment_ref); + let source_artifact_sha256 = trusted_source_artifact_sha256(&manifest)?; + read_environment_attestation(environment, source_artifact_sha256) + .map_err(CliError::Config)?; + let environment_name = environment.file_name().ok_or_else(|| { + CliError::Config(format!( + "managed Python environment {} has no lifecycle environment name", + environment.display() + )) + })?; + closure.record_file( + Path::new(MANAGED_ENVIRONMENTS_DIR) + .join(environment_name) + .join(ENVIRONMENT_ATTESTATION_FILE), + environment.join(ENVIRONMENT_ATTESTATION_FILE), + )?; + } + DynamicPluginManifestLoad::Worker(_) => {} + } + if let Some(artifact) = manifest + .source + .as_ref() + .and_then(|source| source.artifact.as_deref()) + { + collect_declared_runtime_closure_file(&manifest_path, artifact, "artifact", &mut closure)?; + } + if let Some(signature) = manifest + .integrity + .as_ref() + .and_then(|integrity| integrity.signature.as_deref()) + { + collect_declared_runtime_closure_file( + &manifest_path, + signature, + "signature", + &mut closure, + )?; + } + + closure.digest() +} + +fn trusted_source_artifact_sha256(manifest: &DynamicPluginManifest) -> Result<&str, CliError> { + manifest + .integrity + .as_ref() + .and_then(|integrity| integrity.sha256.as_deref()) + .map(str::trim) + .filter(|digest| !digest.is_empty()) + .ok_or_else(|| { + CliError::Config(format!( + "Python worker dynamic plugin '{}' requires integrity.sha256 to bind its complete installed environment to the trusted source artifact", + manifest.plugin.id + )) + }) +} + +enum RuntimeClosureSource { + File(PathBuf), + Bytes(Vec), +} + +#[derive(Default)] +struct RuntimeClosureSources { + files: BTreeMap, + copied_files: HashMap, + entries: usize, +} + +impl RuntimeClosureSources { + fn record_file(&mut self, relative: PathBuf, source: PathBuf) -> Result<(), CliError> { + self.entries = self.entries.saturating_add(1); + self.record_reserved_file(relative, source); + self.enforce_file_budget() + } + + fn record_reserved_file(&mut self, relative: PathBuf, source: PathBuf) { + self.files + .insert(relative.clone(), RuntimeClosureSource::File(source.clone())); + self.copied_files.insert(source, relative); + } + + fn record_bytes(&mut self, relative: PathBuf, bytes: Vec) -> Result<(), CliError> { + self.entries = self.entries.saturating_add(1); + self.files + .insert(relative, RuntimeClosureSource::Bytes(bytes)); + self.enforce_file_budget() + } + + fn enforce_file_budget(&self) -> Result<(), CliError> { + if self.entries > MAX_SNAPSHOT_FILES { + Err(CliError::Config(format!( + "dynamic plugin runtime closure exceeds the {MAX_SNAPSHOT_FILES}-entry activation snapshot budget" + ))) + } else { + Ok(()) + } + } + + fn record_entry(&mut self) -> Result<(), CliError> { + self.entries = self.entries.saturating_add(1); + self.enforce_file_budget() + } + + fn record_entries(&mut self, count: usize) -> Result<(), CliError> { + self.entries = self.entries.saturating_add(count); + self.enforce_file_budget() + } + + fn digest(self) -> Result { + let activation_manifest = Path::new("runtime").join("relay-plugin.toml"); + let mut digest = Sha256::new(); + let mut budget = SnapshotBudget::default(); + for (relative, source) in self.files { + if relative == activation_manifest { + continue; + } + let bytes = match source { + RuntimeClosureSource::File(path) => { + read_bounded_regular_file(&path, "dynamic plugin runtime closure file") + .map_err(CliError::Config)? + } + RuntimeClosureSource::Bytes(bytes) => bytes, + }; + budget.record(&relative, bytes.len())?; + update_snapshot_entry_digest(&mut digest, &relative, SnapshotEntryKind::File, &bytes); + } + Ok(digest + .finalize() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect()) + } +} + +#[derive(Clone, Copy)] +enum SnapshotEntryKind { + File = 0, + Symlink = 1, +} + +fn update_snapshot_entry_digest( + digest: &mut Sha256, + relative: &Path, + kind: SnapshotEntryKind, + payload: &[u8], +) { + let relative = relative.as_os_str().as_encoded_bytes(); + digest.update([kind as u8]); + digest.update((relative.len() as u64).to_le_bytes()); + digest.update(relative); + digest.update((payload.len() as u64).to_le_bytes()); + digest.update(payload); +} + +fn collect_declared_runtime_closure_file( + manifest_path: &Path, + reference: &str, + label: &str, + closure: &mut RuntimeClosureSources, +) -> Result<(), CliError> { + let logical = resolve_manifest_relative_path(manifest_path, reference); + let canonical = fs::canonicalize(&logical).map_err(|error| { + CliError::Config(format!( + "failed to normalize dynamic plugin {label} {}: {error}", + logical.display() + )) + })?; + if closure.copied_files.contains_key(&canonical) && !matches!(label, "library" | "entrypoint") { + return Ok(()); + } + if matches!(label, "library" | "entrypoint") { + let manifest_directory = manifest_path + .parent() + .and_then(|parent| fs::canonicalize(parent).ok()); + if manifest_directory + .as_ref() + .is_some_and(|directory| canonical.starts_with(directory)) + && closure.copied_files.contains_key(&canonical) + { + return Ok(()); + } + let parent = canonical.parent().ok_or_else(|| { + CliError::Config(format!( + "dynamic plugin {label} {} has no parent directory", + canonical.display() + )) + })?; + return collect_runtime_closure_directory( + parent, + Path::new(&format!("external-{label}")), + false, + &mut Vec::new(), + closure, + ); + } + let file_name = canonical.file_name().ok_or_else(|| { + CliError::Config(format!( + "dynamic plugin {label} {} has no file name", + canonical.display() + )) + })?; + closure.record_file( + Path::new(&format!("external-{label}")).join(file_name), + canonical, + ) +} + +fn collect_runtime_closure_directory( + source: &Path, + destination: &Path, + skip_python_cache: bool, + ancestors: &mut Vec, + closure: &mut RuntimeClosureSources, +) -> Result<(), CliError> { + closure.record_entry()?; + collect_runtime_closure_directory_contents( + source, + destination, + skip_python_cache, + ancestors, + closure, + ) +} + +fn collect_runtime_closure_directory_contents( + source: &Path, + destination: &Path, + skip_python_cache: bool, + ancestors: &mut Vec, + closure: &mut RuntimeClosureSources, +) -> Result<(), CliError> { + if ancestors.len() >= MAX_SNAPSHOT_DEPTH { + return Err(CliError::Config(format!( + "dynamic plugin runtime closure exceeds the {MAX_SNAPSHOT_DEPTH}-directory traversal depth at {}", + source.display() + ))); + } + let canonical = fs::canonicalize(source).map_err(|error| { + CliError::Config(format!( + "failed to normalize dynamic plugin runtime directory {}: {error}", + source.display() + )) + })?; + if ancestors.contains(&canonical) { + return Err(CliError::Config(format!( + "dynamic plugin runtime closure contains a directory symlink cycle at {}", + source.display() + ))); + } + ancestors.push(canonical.clone()); + let mut entries = bounded_runtime_directory_entries( + &canonical, + MAX_SNAPSHOT_FILES.saturating_sub(closure.entries), + )?; + closure.record_entries(entries.len())?; + entries.sort_by_key(fs::DirEntry::file_name); + for entry in entries { + let source_path = entry.path(); + if skip_python_cache + && (entry.file_name() == "__pycache__" + || source_path.extension().and_then(|value| value.to_str()) == Some("pyc")) + { + continue; + } + let metadata = fs::symlink_metadata(&source_path) + .map_err(|error| CliError::Config(error.to_string()))?; + let resolved = if metadata.file_type().is_symlink() { + fs::canonicalize(&source_path).map_err(|error| { + CliError::Config(format!( + "failed to resolve dynamic plugin runtime symlink {}: {error}", + source_path.display() + )) + })? + } else { + source_path.clone() + }; + let resolved_metadata = + fs::metadata(&resolved).map_err(|error| CliError::Config(error.to_string()))?; + let relative = destination.join(entry.file_name()); + if resolved_metadata.is_dir() { + collect_runtime_closure_directory_contents( + &resolved, + &relative, + skip_python_cache, + ancestors, + closure, + )?; + } else if resolved_metadata.is_file() { + closure.record_reserved_file(relative, resolved); + } else { + return Err(CliError::Config(format!( + "dynamic plugin runtime entry {} must resolve to a regular file or directory", + source_path.display() + ))); + } + } + ancestors.pop(); + Ok(()) +} + +fn bounded_runtime_directory_entries( + directory: &Path, + remaining_entries: usize, +) -> Result, CliError> { + let mut entries = Vec::new(); + for entry in fs::read_dir(directory).map_err(|error| CliError::Config(error.to_string()))? { + if entries.len() >= remaining_entries { + return Err(CliError::Config(format!( + "dynamic plugin runtime closure exceeds the {MAX_SNAPSHOT_FILES}-entry activation snapshot budget at {}", + directory.display() + ))); + } + entries.push(entry.map_err(|error| CliError::Config(error.to_string()))?); + } + Ok(entries) +} + +fn collect_snapshot_files( + root: &Path, + directory: &Path, + files: &mut Vec, + logical_depth: Option, + entries: &mut usize, +) -> Result<(), CliError> { + if let Some(depth) = logical_depth + && depth >= MAX_SNAPSHOT_DEPTH + { + return Err(CliError::Config(format!( + "dynamic plugin activation snapshot exceeds the {MAX_SNAPSHOT_DEPTH}-directory traversal depth at {}", + directory.display() + ))); + } + let resets_child_depth = + directory == root || directory == root.join(environment::MANAGED_ENVIRONMENTS_DIR); + for entry in fs::read_dir(directory).map_err(|error| CliError::Config(error.to_string()))? { + *entries = entries.saturating_add(1); + if *entries > MAX_SNAPSHOT_FILES { + return Err(CliError::Config(format!( + "dynamic plugin activation snapshot exceeds the {MAX_SNAPSHOT_FILES}-entry verification budget at {}", + directory.display() + ))); + } + let path = entry + .map_err(|error| CliError::Config(error.to_string()))? + .path(); + let metadata = + fs::symlink_metadata(&path).map_err(|error| CliError::Config(error.to_string()))?; + if metadata.is_dir() { + let child_depth = if resets_child_depth { + 0 + } else { + logical_depth.unwrap_or(0).saturating_add(1) + }; + collect_snapshot_files(root, &path, files, Some(child_depth), entries)?; + } else { + files.push( + path.strip_prefix(root) + .map_err(|error| CliError::Config(error.to_string()))? + .to_path_buf(), + ); + } + } + Ok(()) +} + +fn make_snapshot_removable(root: &Path) { + let Ok(entries) = fs::read_dir(root) else { + return; + }; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = fs::set_permissions(root, fs::Permissions::from_mode(0o700)); + } + for entry in entries.flatten() { + let path = entry.path(); + let Ok(metadata) = fs::symlink_metadata(&path) else { + continue; + }; + if metadata.is_dir() { + make_snapshot_removable(&path); + } else { + #[cfg(windows)] + if !metadata.file_type().is_symlink() { + let mut permissions = metadata.permissions(); + permissions.set_readonly(false); + let _ = fs::set_permissions(&path, permissions); + } + } + } +} + +pub(crate) fn active_dynamic_plugin_components( + explicit: Option<&PathBuf>, + resolved: &ResolvedConfig, +) -> Result, CliError> { + active_dynamic_plugin_components_inner(explicit, resolved, true) +} + +pub(crate) fn active_dynamic_plugin_components_for_identity( + explicit: Option<&PathBuf>, + resolved: &ResolvedConfig, +) -> Result, CliError> { + let scopes = load_scoped_registries(explicit)?; + active_dynamic_plugin_components_from_scopes(&scopes, resolved, false) +} + +fn active_dynamic_plugin_components_inner( + explicit: Option<&PathBuf>, + resolved: &ResolvedConfig, + create_activation_snapshots: bool, +) -> Result, CliError> { + let scopes = load_and_hydrate_scopes(explicit, resolved)?; + active_dynamic_plugin_components_from_scopes(&scopes, resolved, create_activation_snapshots) +} + +fn active_dynamic_plugin_components_from_scopes( + scopes: &[ScopedRegistry], + resolved: &ResolvedConfig, + create_activation_snapshots: bool, +) -> Result, CliError> { + let host_config_by_id = host_config_by_id(resolved); + let mut components = Vec::new(); + + for resolved_plugin in &resolved.dynamic_plugins { + let Some(record) = scopes + .iter() + .find(|scope| scope.plugins_toml_path == resolved_plugin.source) + .and_then(|scope| scope.registry.get(&resolved_plugin.plugin_id)) + else { + return Err(CliError::Config(format!( + "dynamic plugin '{}' is present in resolved config but not lifecycle state", + resolved_plugin.plugin_id + ))); + }; + if record.is_tombstoned() || !record.spec.enabled { + continue; + } + let host_config = host_config_by_id.get(&record.metadata.id).ok_or_else(|| { + CliError::Config(format!( + "dynamic plugin '{}' is enabled but has no resolved host config", + record.metadata.id + )) + })?; + let manifest_ref = match record.metadata.kind { + DynamicPluginKind::RustDynamic => Some(manifest_ref_from_record(record)?), + DynamicPluginKind::Worker => record.source.manifest_ref.clone(), + }; + let activation_snapshot = if create_activation_snapshots { + manifest_ref + .as_deref() + .map(|manifest_ref| { + DynamicPluginActivationSnapshot::create( + manifest_ref, + &record.metadata.id, + record.metadata.kind, + record.source.environment_ref.as_deref(), + &resolved.dynamic_plugin_policy, + ) + }) + .transpose()? + } else { + None + }; + components.push(ActiveDynamicPluginComponent { + plugin_id: record.metadata.id.clone(), + kind: record.metadata.kind, + lifecycle_generation: record.metadata.generation, + manifest_ref, + environment_ref: record.source.environment_ref.clone(), + config: host_config.config.clone(), + activation_snapshot, + }); + } + + Ok(components) +} + +fn mutate_enabled_state( + plugin_id: String, + server: &GatewayOverrides, + enabled: bool, +) -> Result<(), CliError> { + let command = if enabled { + "plugins enable" + } else { + "plugins disable" + }; + let mut scopes = if enabled { + let resolved = resolve_plugins_config(server.config.as_ref())?; + let mut scopes = load_and_hydrate_scopes(server.config.as_ref(), &resolved)?; + let entry = find_registered_entry(&scopes, command, &plugin_id)?; + if entry.record.is_tombstoned() { + return Err(plugin_refused( + command, + Some(plugin_id.clone()), + format!( + "dynamic plugin '{}' is tombstoned and cannot be {}d", + plugin_id, + if enabled { "enable" } else { "disable" } + ), + )); + } + let manifest_ref = manifest_ref_from_record(&entry.record)?; + let (manifest, manifest_ref) = load_manifest_for_action(command, &manifest_ref)?; + let policy = + evaluate_dynamic_plugin_host_policy(&resolved.dynamic_plugin_policy, &manifest); + let trust = evaluate_dynamic_plugin_trust(&manifest, &manifest_ref, &policy); + update_registry_validation_status( + &mut scopes[entry.scope_index], + &plugin_id, + &manifest, + &policy, + &trust, + )?; + if !policy.policy_satisfied { + scopes[entry.scope_index].save()?; + return Err(plugin_refused_with_code( + command, + Some(plugin_id.clone()), + "policy_blocked", + policy + .failure() + .map(|failure| failure.display(&plugin_id).to_string()) + .unwrap_or_else(|| { + format!("dynamic plugin '{}' is blocked by host policy", plugin_id) + }), + )); + } + if let Some(failure) = trust.failure() { + scopes[entry.scope_index].save()?; + return Err(plugin_refused_with_code( + command, + Some(plugin_id.clone()), + trust_refusal_code(&trust), + failure.display(&plugin_id).to_string(), + )); + } + if let Some(environment_error) = scopes[entry.scope_index] + .registry + .get(&plugin_id) + .and_then(|record| record.status.last_error.as_ref()) + .filter(|error| error.code == "environment_failed") + { + let message = environment_error.message.clone(); + scopes[entry.scope_index].save()?; + return Err(plugin_refused_with_code( + command, + Some(plugin_id.clone()), + "environment_failed", + message, + )); + } + scopes + } else { + load_scoped_registries(server.config.as_ref())? + }; + let entry = find_registered_entry(&scopes, command, &plugin_id)?; + if entry.record.is_tombstoned() { + return Err(plugin_refused( + command, + Some(plugin_id.clone()), + format!( + "dynamic plugin '{}' is tombstoned and cannot be {}d", + plugin_id, + if enabled { "enable" } else { "disable" } + ), + )); + } + if enabled { + scopes[entry.scope_index] + .registry + .enable(&plugin_id) + .map_err(|error| CliError::Config(error.to_string()))?; + } else { + scopes[entry.scope_index] + .registry + .disable(&plugin_id) + .map_err(|error| CliError::Config(error.to_string()))?; + } + scopes[entry.scope_index].save()?; + + println!( + "{} dynamic plugin {}", + if enabled { "Enabled" } else { "Disabled" }, + plugin_id + ); + Ok(()) +} + +fn load_and_hydrate_scopes( + explicit: Option<&PathBuf>, + resolved: &ResolvedConfig, +) -> Result, CliError> { + let (scopes, touched_scope_indices) = load_and_hydrate_scopes_with_updates(explicit, resolved)?; + for scope_index in touched_scope_indices { + scopes[scope_index].save()?; + } + Ok(scopes) +} + +fn load_and_hydrate_scopes_with_updates( + explicit: Option<&PathBuf>, + resolved: &ResolvedConfig, +) -> Result<(Vec, Vec), CliError> { + let mut scopes = load_scoped_registries(explicit)?; + let mut touched_scope_indices = BTreeSet::new(); + for plugin in &resolved.dynamic_plugins { + let scope_index = scopes + .iter() + .position(|scope| scope.plugins_toml_path == plugin.source) + .ok_or_else(|| { + CliError::Config(format!( + "dynamic plugin '{}' resolved from {} but no matching lifecycle scope exists", + plugin.plugin_id, + plugin.source.display() + )) + })?; + touched_scope_indices.insert(scope_index); + let (manifest, manifest_ref) = load_manifest_for_action("hydrate", &plugin.manifest_ref)?; + let policy = + evaluate_dynamic_plugin_host_policy(&resolved.dynamic_plugin_policy, &manifest); + let trust = evaluate_dynamic_plugin_trust(&manifest, &manifest_ref, &policy); + if find_record_by_id(&scopes, &plugin.plugin_id)?.is_some() { + update_registry_validation_status( + &mut scopes[scope_index], + &plugin.plugin_id, + &manifest, + &policy, + &trust, + )?; + } else { + let state_path = scopes[scope_index].state_path.clone(); + let record = validated_record_from_manifest( + manifest, + manifest_ref, + None, + &state_path, + &policy, + &trust, + )?; + scopes[scope_index] + .registry + .add(record) + .map_err(|error| CliError::Config(error.to_string()))?; + } + } + Ok((scopes, touched_scope_indices.into_iter().collect())) +} + +fn validated_record_from_manifest( + manifest: DynamicPluginManifest, + manifest_ref: String, + environment_ref: Option, + state_path: &Path, + policy: &EvaluatedDynamicPluginHostPolicy, + trust: &EvaluatedDynamicPluginTrust, +) -> Result { + let environment = environment_state(&manifest, state_path, environment_ref.as_deref()); + let mut record = manifest + .into_record(Some(manifest_ref)) + .map_err(|error| CliError::Config(error.to_string()))?; + record.source.environment_ref = environment_ref; + record.status.validation = DynamicPluginValidationStatus { + manifest: DynamicPluginCheckState::Valid, + compatibility: DynamicPluginCheckState::Valid, + integrity: trust.integrity, + environment, + authenticity: trust.authenticity, + policy_satisfied: policy.check_state(), + checked_at: None, + message: Some(VALIDATION_MESSAGE.into()), + }; + record.status.startup_class = Some(policy.startup_class); + record.status.attestation_mode = Some(policy.attestation_mode); + record.status.last_error = policy + .last_error(&record.metadata.id) + .or_else(|| trust.last_error(&record.metadata.id)) + .or_else(|| { + environment_last_error( + &record.metadata.id, + environment, + record.source.environment_ref.as_deref(), + ) + }); + Ok(record) +} + +fn host_config_by_id(resolved: &ResolvedConfig) -> HashMap { + resolved + .dynamic_plugins + .iter() + .cloned() + .map(|plugin| (plugin.plugin_id.clone(), plugin)) + .collect() +} + +fn update_registry_policy_status( + scope: &mut ScopedRegistry, + plugin_id: &str, + policy: &EvaluatedDynamicPluginHostPolicy, +) -> Result<(), CliError> { + scope + .registry + .update_policy_status( + plugin_id, + policy.check_state(), + policy.startup_class, + policy.attestation_mode, + policy.last_error(plugin_id), + ) + .map_err(|error| CliError::Config(error.to_string())) +} + +fn update_registry_validation_status( + scope: &mut ScopedRegistry, + plugin_id: &str, + manifest: &DynamicPluginManifest, + policy: &EvaluatedDynamicPluginHostPolicy, + trust: &EvaluatedDynamicPluginTrust, +) -> Result<(), CliError> { + let environment_ref = scope + .registry + .get(plugin_id) + .and_then(|record| record.source.environment_ref.as_deref()); + let environment = environment_state(manifest, &scope.state_path, environment_ref); + let environment_error = environment_last_error(plugin_id, environment, environment_ref); + scope + .registry + .update_validation_status( + plugin_id, + DynamicPluginValidationStatus { + manifest: DynamicPluginCheckState::Valid, + compatibility: DynamicPluginCheckState::Valid, + integrity: trust.integrity, + environment, + authenticity: trust.authenticity, + policy_satisfied: policy.check_state(), + checked_at: None, + message: Some(VALIDATION_MESSAGE.into()), + }, + ) + .map_err(|error| CliError::Config(error.to_string()))?; + update_registry_policy_status(scope, plugin_id, policy)?; + scope + .registry + .update_last_error( + plugin_id, + policy + .last_error(plugin_id) + .or_else(|| trust.last_error(plugin_id)) + .or(environment_error), + ) + .map_err(|error| CliError::Config(error.to_string())) +} + +fn environment_last_error( + plugin_id: &str, + environment: DynamicPluginCheckState, + environment_ref: Option<&str>, +) -> Option { + (environment == DynamicPluginCheckState::Invalid).then(|| DynamicPluginFailure { + phase: DynamicPluginFailurePhase::Validation, + code: "environment_failed".into(), + message: environment_ref.map_or_else( + || { + format!( + "dynamic plugin '{}' has no lifecycle-managed Python environment; run `nemo-relay plugins remove {}` to remove the manual registration, then run `nemo-relay plugins add `", + plugin_id, plugin_id + ) + }, + |environment_ref| { + format!( + "dynamic plugin '{}' configured Python environment {} is unavailable", + plugin_id, environment_ref + ) + }, + ), + }) +} + +fn find_registered_entry( + scopes: &[ScopedRegistry], + command: &'static str, + plugin_id: &str, +) -> Result { + find_record_by_id(scopes, plugin_id)?.ok_or_else(|| { + plugin_not_found( + command, + Some(plugin_id.to_owned()), + format!( + "dynamic plugin '{}' is not registered; run `nemo-relay plugins add `", + plugin_id + ), + ) + }) +} + +fn load_manifest_for_action( + action: &str, + path: impl Into, +) -> Result<(DynamicPluginManifest, String), CliError> { + let path = path.into(); + crate::configuration::load_bounded_dynamic_plugin_manifest(&path) + .map_err(|error| CliError::Config(format!("dynamic plugin {action} failed: {error}"))) +} + +fn load_config_schema_for_manifest( + manifest: &DynamicPluginManifest, + manifest_ref: &str, +) -> Result, CliError> { + let schema_path = manifest + .resolve_config_schema_path(manifest_ref) + .map_err(|error| { + CliError::Config(format!( + "dynamic plugin '{}' config schema path could not be resolved from '{}': {error}", + manifest.plugin.id, manifest_ref + )) + })?; + schema_path + .map(|path| PluginConfigSchema::load(manifest.plugin.id.trim(), path)) + .transpose() +} + +fn manifest_ref_from_record(record: &DynamicPluginRecord) -> Result { + record.source.manifest_ref.clone().ok_or_else(|| { + CliError::Config(format!( + "dynamic plugin '{}' has no manifest_ref in lifecycle state", + record.metadata.id + )) + }) +} + +fn ensure_scope( + scopes: &mut Vec, + scope: RegistryScope, + plugins_toml_path: PathBuf, + state_path: PathBuf, +) -> usize { + if let Some(index) = scopes.iter().position(|existing| { + existing.scope == scope + && existing.plugins_toml_path == plugins_toml_path + && existing.state_path == state_path + }) { + return index; + } + scopes.push(ScopedRegistry { + scope, + plugins_toml_path, + state_path, + registry: nemo_relay::plugin::dynamic::DynamicPluginRegistry::new(), + }); + scopes.len() - 1 +} + +fn scope_flags_selected(scope: &crate::plugins::ConfigurationScope) -> bool { + !matches!(scope, crate::plugins::ConfigurationScope::Default) +} + +fn restore_plugins_toml(path: &std::path::Path, original: Option<&[u8]>) -> Result<(), CliError> { + match original { + Some(bytes) => std::fs::write(path, bytes)?, + None if path.exists() => std::fs::remove_file(path)?, + None => {} + } + Ok(()) +} + +fn required_startup_failure( + entry: &ScopedDynamicPluginRecord, + resolved_plugins: &[ResolvedDynamicPluginConfig], +) -> Option { + if entry.record.status.startup_class + != Some(nemo_relay::plugin::dynamic::DynamicPluginStartupClass::Required) + { + return None; + } + + if entry.record.status.validation.policy_satisfied == DynamicPluginCheckState::Invalid { + return Some(format!( + "- {}: {}", + entry.record.metadata.id, + entry + .record + .status + .last_error + .as_ref() + .map(|error| error.message.as_str()) + .unwrap_or("blocked by host policy") + )); + } + if entry.record.status.validation.integrity == DynamicPluginCheckState::Invalid + || entry.record.status.validation.authenticity == DynamicPluginCheckState::Invalid + { + return Some(format!( + "- {}: {}", + entry.record.metadata.id, + entry + .record + .status + .last_error + .as_ref() + .map(|error| error.message.as_str()) + .unwrap_or("required dynamic plugin trust verification failed") + )); + } + if entry.record.status.validation.environment == DynamicPluginCheckState::Invalid { + return Some(format!( + "- {}: {}", + entry.record.metadata.id, + entry + .record + .status + .last_error + .as_ref() + .map(|error| error.message.as_str()) + .unwrap_or("required dynamic plugin environment is unavailable") + )); + } + + let manifest_ref = entry + .record + .source + .manifest_ref + .as_deref() + .map(Path::new) + .map(Path::to_path_buf); + if manifest_ref.is_none() { + return Some(format!( + "- {}: required dynamic plugin has no manifest_ref in lifecycle state", + entry.record.metadata.id + )); + } + + let manifest_ref = manifest_ref.expect("manifest_ref checked above"); + if !resolved_plugins + .iter() + .any(|plugin| plugin.plugin_id == entry.record.metadata.id) + { + if !manifest_ref.exists() { + return Some(format!( + "- {}: required dynamic plugin manifest is no longer available at {}", + entry.record.metadata.id, + manifest_ref.display() + )); + } + + if let Err(error) = + crate::configuration::load_bounded_dynamic_plugin_manifest(&manifest_ref) + { + return Some(format!( + "- {}: required dynamic plugin manifest at {} is unreadable: {}", + entry.record.metadata.id, + manifest_ref.display(), + error + )); + } + } + + None +} + +#[cfg(test)] +#[path = "../../../tests/coverage/shared/plugins_lifecycle_tests.rs"] +mod tests; diff --git a/crates/cli/src/plugins/lifecycle/render.rs b/crates/cli/src/plugins/lifecycle/render.rs new file mode 100644 index 000000000..4ffabd95e --- /dev/null +++ b/crates/cli/src/plugins/lifecycle/render.rs @@ -0,0 +1,405 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Human and machine-readable lifecycle result rendering. + +use super::*; + +pub(crate) fn render_plugin_error( + error: &CliError, + json: bool, +) -> Result, CliError> { + let Some((command, target, kind, code, message)) = error.as_plugin_lifecycle_error_context() + else { + return Ok(None); + }; + + let exit_code = match kind { + PluginLifecycleFailureKind::Failed => ExitCode::from(1), + PluginLifecycleFailureKind::NotFound => ExitCode::from(2), + PluginLifecycleFailureKind::Refused => ExitCode::from(3), + }; + + if json { + print_response_json(&failure(command, target, kind, code, message))?; + } else { + eprintln!("{message}"); + } + Ok(Some(exit_code)) +} + +pub(crate) fn render_generic_plugin_json_error( + command: &'static str, + target: Option<&str>, + message: &str, +) -> Result { + print_response_json(&generic_failure(command, target, message))?; + Ok(ExitCode::from(1)) +} + +pub(super) fn plugin_not_found( + command: &'static str, + target: Option, + message: impl Into, +) -> CliError { + CliError::PluginLifecycle { + command, + target, + kind: PluginLifecycleFailureKind::NotFound, + code: None, + message: message.into(), + } +} + +pub(super) fn plugin_refused( + command: &'static str, + target: Option, + message: impl Into, +) -> CliError { + plugin_refused_with_code(command, target, "refused", message) +} + +pub(super) fn plugin_refused_with_code( + command: &'static str, + target: Option, + code: &'static str, + message: impl Into, +) -> CliError { + CliError::PluginLifecycle { + command, + target, + kind: PluginLifecycleFailureKind::Refused, + code: Some(code), + message: message.into(), + } +} + +pub(super) fn plugin_failed_with_code( + command: &'static str, + target: Option, + code: &'static str, + message: impl Into, +) -> CliError { + CliError::PluginLifecycle { + command, + target, + kind: PluginLifecycleFailureKind::Failed, + code: Some(code), + message: message.into(), + } +} + +pub(super) fn trust_refusal_code(trust: &EvaluatedDynamicPluginTrust) -> &'static str { + trust.refusal_code().unwrap_or("refused") +} + +pub(super) fn list_validation_state(record: &DynamicPluginRecord) -> DynamicPluginCheckState { + let validation = &record.status.validation; + if validation.manifest == DynamicPluginCheckState::Invalid + || validation.compatibility == DynamicPluginCheckState::Invalid + || validation.integrity == DynamicPluginCheckState::Invalid + || validation.environment == DynamicPluginCheckState::Invalid + || validation.authenticity == DynamicPluginCheckState::Invalid + || validation.policy_satisfied == DynamicPluginCheckState::Invalid + { + DynamicPluginCheckState::Invalid + } else if validation.manifest == DynamicPluginCheckState::Unknown + || validation.compatibility == DynamicPluginCheckState::Unknown + { + DynamicPluginCheckState::Unknown + } else { + DynamicPluginCheckState::Valid + } +} + +pub(super) struct PluginListView<'a> { + pub(super) records: &'a [ScopedDynamicPluginRecord], + pub(super) host_config_by_id: &'a HashMap, +} + +impl fmt::Display for PluginListView<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let widths = PluginListWidths::from_records(self.records); + + write!( + f, + "{: Self { + Self { + id: column_width( + "ID", + records + .iter() + .map(|entry| entry.record.metadata.id.as_str()), + ), + scope: column_width( + "SCOPE", + records.iter().map(|entry| { + let scope: &'static str = entry.scope.into(); + scope + }), + ), + enabled: column_width( + "ENABLED", + records.iter().map(|entry| { + if entry.record.spec.enabled { + "true" + } else { + "false" + } + }), + ), + state: column_width( + "STATE", + records + .iter() + .map(|entry| lifecycle_state_label(&entry.record)), + ), + validation: column_width( + "VALIDATION", + records.iter().map(|entry| { + let validation: &'static str = list_validation_state(&entry.record).into(); + validation + }), + ), + policy: column_width( + "POLICY", + records.iter().map(|entry| { + let policy: &'static str = + entry.record.status.validation.policy_satisfied.into(); + policy + }), + ), + } + } +} + +pub(super) fn column_width<'a>( + header: &'static str, + values: impl Iterator, +) -> usize { + values + .map(str::len) + .chain(std::iter::once(header.len())) + .max() + .unwrap_or(header.len()) +} + +pub(super) struct PluginInspectView<'a> { + pub(super) entry: &'a ScopedDynamicPluginRecord, + pub(super) manifest: &'a DynamicPluginManifest, + pub(super) manifest_ref: &'a str, + pub(super) host_config: Option<&'a ResolvedDynamicPluginConfig>, +} + +impl fmt::Display for PluginInspectView<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let view = inspect_data( + self.entry, + self.manifest, + self.manifest_ref, + self.host_config, + ); + let yaml = serde_yaml::to_string(&view).map_err(|_| fmt::Error)?; + write!(f, "{}", yaml.trim_end()) + } +} + +pub(super) struct PluginValidationSummaryView<'a> { + pub(super) manifest: &'a DynamicPluginManifest, + pub(super) manifest_ref: &'a str, + pub(super) entry: Option<&'a ScopedDynamicPluginRecord>, + pub(super) host_config: Option<&'a ResolvedDynamicPluginConfig>, + pub(super) policy: &'a EvaluatedDynamicPluginHostPolicy, + pub(super) trust: &'a EvaluatedDynamicPluginTrust, +} + +impl fmt::Display for PluginValidationSummaryView<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let environment = self + .entry + .map(|entry| entry.record.status.validation.environment) + .unwrap_or(DynamicPluginCheckState::Unknown); + if self.policy.policy_satisfied + && self.trust.is_satisfied() + && environment != DynamicPluginCheckState::Invalid + { + writeln!(f, "Dynamic plugin '{}' is valid.", self.manifest.plugin.id)?; + } else if self.policy.policy_satisfied + && self.trust.is_satisfied() + && environment == DynamicPluginCheckState::Invalid + { + writeln!( + f, + "Dynamic plugin '{}' manifest is valid, but its runtime environment is unavailable.", + self.manifest.plugin.id + )?; + } else if self.policy.policy_satisfied { + writeln!( + f, + "Dynamic plugin '{}' manifest is valid, but trust verification blocks it.", + self.manifest.plugin.id + )?; + } else { + writeln!( + f, + "Dynamic plugin '{}' manifest is valid, but host policy blocks it.", + self.manifest.plugin.id + )?; + } + writeln!(f, "kind: {}", self.manifest.plugin.kind)?; + writeln!( + f, + "policy_state: {}", + <&'static str>::from(self.policy.check_state()) + )?; + writeln!( + f, + "integrity_state: {}", + <&'static str>::from(self.trust.integrity) + )?; + writeln!( + f, + "environment_state: {}", + <&'static str>::from(environment) + )?; + writeln!( + f, + "authenticity_state: {}", + <&'static str>::from(self.trust.authenticity) + )?; + writeln!(f, "startup_class: {}", self.policy.startup_class)?; + writeln!(f, "attestation_mode: {}", self.policy.attestation_mode)?; + if let Some(failure) = self.policy.failure() { + writeln!( + f, + "policy_error: {}", + failure.display(&self.manifest.plugin.id) + )?; + } + if let Some(failure) = self.trust.failure() { + writeln!( + f, + "trust_error: {}", + failure.display(&self.manifest.plugin.id) + )?; + } + if let Some(entry) = self.entry { + writeln!(f, "manifest: {}", self.manifest_ref)?; + writeln!(f, "scope: {}", entry.scope)?; + writeln!(f, "lifecycle_state_path: {}", entry.state_path.display())?; + writeln!(f, "desired.enabled: {}", entry.record.spec.enabled)?; + write!(f, "host_config: {}", host_config_label(self.host_config))?; + } else { + write!(f, "manifest: {}", self.manifest_ref)?; + } + Ok(()) + } +} + +pub(super) fn lifecycle_state_label(record: &DynamicPluginRecord) -> &'static str { + if record.is_tombstoned() { + "tombstoned" + } else { + record.status.runtime.state.into() + } +} + +pub(super) fn host_config_label(host_config: Option<&ResolvedDynamicPluginConfig>) -> &'static str { + host_config + .map(|plugin| { + let status: &'static str = plugin.host_config_status().into(); + status + }) + .unwrap_or("absent") +} + +pub(super) fn redacted_host_config_json(host_config: &ResolvedDynamicPluginConfig) -> Value { + if host_config.config.is_empty() && !host_config.has_explicit_config { + return Value::Null; + } + + Value::Object( + host_config + .config + .keys() + .cloned() + .map(|key| (key, Value::String("".into()))) + .collect(), + ) +} + +pub(super) fn inspect_load_data(record: &DynamicPluginRecord) -> Value { + match &record.load { + DynamicPluginLoadContract::Worker(load) => serde_json::json!({ + "runtime": load.runtime, + "entrypoint": load.entrypoint, + }), + DynamicPluginLoadContract::RustDynamic(load) => serde_json::json!({ + "library": load.library, + "symbol": load.symbol, + }), + } +} + +pub(super) fn inspect_compat_data(record: &DynamicPluginRecord) -> Value { + match &record.compatibility { + DynamicPluginCompatibility::Worker(compatibility) => serde_json::json!({ + "relay": compatibility.relay, + "worker_protocol": compatibility.worker_protocol, + }), + DynamicPluginCompatibility::RustDynamic(compatibility) => serde_json::json!({ + "relay": compatibility.relay, + "native_api": compatibility.native_api, + }), + } +} diff --git a/crates/cli/src/plugins/lifecycle/responses.rs b/crates/cli/src/plugins/lifecycle/responses.rs index 0eb1ace6c..247e990ec 100644 --- a/crates/cli/src/plugins/lifecycle/responses.rs +++ b/crates/cli/src/plugins/lifecycle/responses.rs @@ -17,7 +17,7 @@ use nemo_relay::plugin::dynamic::{ use serde::Serialize; use serde_json::{Map, Value}; -use crate::config::{DynamicPluginHostConfigStatus, ResolvedDynamicPluginConfig}; +use crate::configuration::{DynamicPluginHostConfigStatus, ResolvedDynamicPluginConfig}; use crate::error::{CliError, PluginLifecycleFailureKind}; use crate::plugins::policy::EvaluatedDynamicPluginHostPolicy; diff --git a/crates/cli/src/plugins/lifecycle/state.rs b/crates/cli/src/plugins/lifecycle/state.rs index 6d047d514..5d01aab22 100644 --- a/crates/cli/src/plugins/lifecycle/state.rs +++ b/crates/cli/src/plugins/lifecycle/state.rs @@ -9,7 +9,7 @@ use nemo_relay::plugin::dynamic::{DynamicPluginRecord, DynamicPluginRegistry}; use serde::{Deserialize, Serialize}; use strum::{Display, IntoStaticStr}; -use crate::config::{ +use crate::configuration::{ PLUGINS_TOML, global_plugin_config_path, project_plugin_config_path, user_config_dir, user_plugin_config_path, }; diff --git a/crates/cli/src/plugins/lifecycle/target.rs b/crates/cli/src/plugins/lifecycle/target.rs index a423bb328..c0f865c06 100644 --- a/crates/cli/src/plugins/lifecycle/target.rs +++ b/crates/cli/src/plugins/lifecycle/target.rs @@ -51,5 +51,5 @@ fn should_treat_target_as_path(target: &str) -> bool { || target.contains('\\') } #[cfg(test)] -#[path = "../../../tests/coverage/plugins_lifecycle_target_tests.rs"] +#[path = "../../../tests/coverage/shared/plugins_lifecycle_target_tests.rs"] mod tests; diff --git a/crates/cli/src/plugins/lifecycle/trust.rs b/crates/cli/src/plugins/lifecycle/trust.rs index b3c6b225f..97a366671 100644 --- a/crates/cli/src/plugins/lifecycle/trust.rs +++ b/crates/cli/src/plugins/lifecycle/trust.rs @@ -2,7 +2,6 @@ // SPDX-License-Identifier: Apache-2.0 use std::fmt; -use std::fs; use std::path::{Path, PathBuf}; use base64::Engine; @@ -318,11 +317,14 @@ fn verify_signature( let signature_path = resolve_artifact_path(manifest_ref, signature_ref); let signature_bytes = read_signature_bytes(&signature_path)?; - let artifact_bytes = - fs::read(artifact_path).map_err(|error| DynamicPluginTrustFailure::ArtifactRead { - path: artifact_path.to_path_buf(), - error: error.to_string(), - })?; + let artifact_bytes = crate::filesystem::bounded::read_bounded_regular_file( + artifact_path, + "dynamic plugin artifact", + ) + .map_err(|error| DynamicPluginTrustFailure::ArtifactRead { + path: artifact_path.to_path_buf(), + error, + })?; let mut parse_errors = Vec::new(); for trusted_public_key in trusted_public_keys { @@ -348,10 +350,12 @@ fn verify_signature( } fn read_signature_bytes(path: &Path) -> TrustResult> { - let raw = fs::read(path).map_err(|error| DynamicPluginTrustFailure::SignatureRead { - path: path.to_path_buf(), - error: error.to_string(), - })?; + let raw = + crate::filesystem::bounded::read_bounded_regular_file(path, "dynamic plugin signature") + .map_err(|error| DynamicPluginTrustFailure::SignatureRead { + path: path.to_path_buf(), + error, + })?; let trimmed = String::from_utf8_lossy(&raw).trim().to_owned(); if trimmed.is_empty() { return Err(DynamicPluginTrustFailure::SignatureRead { @@ -400,9 +404,15 @@ fn resolve_artifact_path(manifest_ref: &str, artifact_ref: &str) -> PathBuf { } fn file_sha256(path: &Path) -> Result { - let bytes = fs::read(path)?; let mut digest = Sha256::new(); - digest.update(&bytes); + crate::filesystem::bounded::stream_bounded_regular_file( + path, + "dynamic plugin artifact", + |bytes| { + digest.update(bytes); + }, + ) + .map_err(std::io::Error::other)?; Ok(format!( "sha256:{}", digest diff --git a/crates/cli/src/plugins.rs b/crates/cli/src/plugins/mod.rs similarity index 99% rename from crates/cli/src/plugins.rs rename to crates/cli/src/plugins/mod.rs index 41191d328..74116e01e 100644 --- a/crates/cli/src/plugins.rs +++ b/crates/cli/src/plugins/mod.rs @@ -16,7 +16,6 @@ use dialoguer::{Input, Select}; use nemo_relay::config_editor::{EditorFieldKind, EditorFieldSpec}; use serde_json::{Value, json}; -use crate::config::PluginsEditCommand; use crate::error::CliError; pub(crate) mod config_io; @@ -24,7 +23,11 @@ mod dynamic_editor; mod editor_model; pub(crate) mod lifecycle; pub(crate) mod policy; +pub(crate) mod pricing; pub(crate) mod schema; +mod types; + +pub(crate) use types::*; use self::config_io::*; use self::dynamic_editor::*; @@ -99,7 +102,7 @@ fn print_save_success(path: &Path) { ); } -pub(crate) fn edit(command: PluginsEditCommand) -> Result<(), CliError> { +pub(crate) fn edit(command: PluginsEditRequest) -> Result<(), CliError> { ensure_tty()?; let scope = target_scope(&command.scope)?; let path = target_path(scope)?; @@ -1790,6 +1793,9 @@ fn clear_value_field( let Some(field) = schema.fields.get(selected) else { return false; }; + if !field.optional { + return false; + } remove_value_field(value, field.name); true } @@ -1985,5 +1991,5 @@ fn editor_error(err: dialoguer::Error) -> CliError { } #[cfg(test)] -#[path = "../tests/coverage/plugins_tests.rs"] +#[path = "../../tests/coverage/shared/plugins_tests.rs"] mod tests; diff --git a/crates/cli/src/model_pricing.rs b/crates/cli/src/plugins/pricing.rs similarity index 85% rename from crates/cli/src/model_pricing.rs rename to crates/cli/src/plugins/pricing.rs index d088337a5..5112ee00d 100644 --- a/crates/cli/src/model_pricing.rs +++ b/crates/cli/src/plugins/pricing.rs @@ -12,16 +12,19 @@ use nemo_relay::codec::response::Usage; use nemo_relay::plugin::{PluginComponentSpec, PluginConfig}; use serde_json::Value; -use crate::config::{ - PricingAddSourceCommand, PricingInitCommand, PricingResolveCommand, PricingScopeArgs, - PricingValidateCommand, ServerArgs, resolve_server_config, +use crate::configuration::resolve_server_config; +use crate::server::GatewayOverrides; + +use super::{ + ConfigurationScope, PricingAddSourceRequest, PricingInitRequest, PricingResolveRequest, + PricingValidateRequest, }; use crate::error::CliError; use crate::plugins::config_io::{PluginConfigDocument, TargetScope, target_path, validate_config}; const PRICING_PLUGIN_KIND: &str = "pricing"; -pub(crate) fn validate(command: PricingValidateCommand) -> Result<(), CliError> { +pub(crate) fn validate(command: PricingValidateRequest) -> Result<(), CliError> { let catalog = read_pricing_catalog(&command.path)?; let entries = catalog.entries.len(); println!( @@ -32,7 +35,7 @@ pub(crate) fn validate(command: PricingValidateCommand) -> Result<(), CliError> Ok(()) } -pub(crate) fn init(command: PricingInitCommand) -> Result<(), CliError> { +pub(crate) fn init(command: PricingInitRequest) -> Result<(), CliError> { let scope = target_pricing_scope(&command.scope)?; let path = target_path(scope)?; update_plugin_config_document(&path, |plugin_config| { @@ -46,7 +49,7 @@ pub(crate) fn init(command: PricingInitCommand) -> Result<(), CliError> { Ok(()) } -pub(crate) fn add_source(command: PricingAddSourceCommand) -> Result<(), CliError> { +pub(crate) fn add_source(command: PricingAddSourceRequest) -> Result<(), CliError> { let source_path = std::fs::canonicalize(&command.path).map_err(|source| { CliError::Config(format!( "could not canonicalize model pricing catalog '{}': {source}", @@ -90,7 +93,7 @@ fn update_plugin_config_document( document.write() } -pub(crate) fn resolve(command: PricingResolveCommand) -> Result<(), CliError> { +pub(crate) fn resolve(command: PricingResolveRequest) -> Result<(), CliError> { let sources = pricing_catalog_sources_from_current_config()?; if sources.is_empty() { return Err(CliError::Config( @@ -138,9 +141,17 @@ pub(crate) fn resolve(command: PricingResolveCommand) -> Result<(), CliError> { } fn read_pricing_catalog(path: &Path) -> Result { - let raw = std::fs::read_to_string(path).map_err(|source| { + let bytes = + crate::filesystem::bounded::read_bounded_regular_file(path, "model pricing catalog") + .map_err(|source| { + CliError::Config(format!( + "could not read model pricing catalog '{}': {source}", + path.display() + )) + })?; + let raw = String::from_utf8(bytes).map_err(|source| { CliError::Config(format!( - "could not read model pricing catalog '{}': {source}", + "model pricing catalog '{}' is not valid UTF-8: {source}", path.display() )) })?; @@ -165,7 +176,7 @@ struct ResolvedPricing { } fn pricing_catalog_sources_from_current_config() -> Result, CliError> { - let resolved = resolve_server_config(&ServerArgs::default())?; + let resolved = resolve_server_config(&GatewayOverrides::default())?; let Some(plugin_config) = resolved.gateway.plugin_config else { return Ok(vec![]); }; @@ -217,22 +228,14 @@ fn resolve_pricing( }) } -fn target_pricing_scope(scope: &PricingScopeArgs) -> Result { - let selected = [scope.user, scope.project, scope.global] - .into_iter() - .filter(|selected| *selected) - .count(); - if selected > 1 { - return Err(CliError::Config( +fn target_pricing_scope(scope: &ConfigurationScope) -> Result { + match scope { + ConfigurationScope::Default | ConfigurationScope::User => Ok(TargetScope::User), + ConfigurationScope::Project => Ok(TargetScope::Project), + ConfigurationScope::Global => Ok(TargetScope::Global), + ConfigurationScope::Invalid => Err(CliError::Config( "choose only one of --user, --project, or --global".into(), - )); - } - if scope.project { - Ok(TargetScope::Project) - } else if scope.global { - Ok(TargetScope::Global) - } else { - Ok(TargetScope::User) + )), } } @@ -285,5 +288,5 @@ fn plural<'a>(count: usize, singular: &'a str, plural: &'a str) -> &'a str { } #[cfg(test)] -#[path = "../tests/coverage/model_pricing_tests.rs"] +#[path = "../../tests/coverage/commands/model_pricing_tests.rs"] mod tests; diff --git a/crates/cli/src/plugins/schema.rs b/crates/cli/src/plugins/schema/mod.rs similarity index 64% rename from crates/cli/src/plugins/schema.rs rename to crates/cli/src/plugins/schema/mod.rs index 865ebcc84..4d505fad9 100644 --- a/crates/cli/src/plugins/schema.rs +++ b/crates/cli/src/plugins/schema/mod.rs @@ -3,6 +3,10 @@ //! Static JSON Schema loading and editor metadata for dynamic plugins. +mod secrets; + +use secrets::*; + use std::cmp::Ordering; use std::collections::{BTreeMap, HashSet}; use std::fs; @@ -1229,12 +1233,13 @@ fn resolve_schema_chain<'a>( return Ok(schema); }; let fragment = decode_reference_fragment(reference)?; - let canonical_reference = format!("#{fragment}"); + let resource = active_schema_resource(root, schema).unwrap_or(root); + let target = resolve_fragment(resource, &fragment) + .ok_or_else(|| ResolveError::Missing(reference.to_owned()))?; + let canonical_reference = format!("{:p}#{fragment}", resource); if !references.insert(canonical_reference.clone()) { - return Err(ResolveError::Cycle(canonical_reference)); + return Err(ResolveError::Cycle(reference.to_owned())); } - let target = resolve_fragment(root, &fragment) - .ok_or_else(|| ResolveError::Missing(reference.to_owned()))?; resolve_schema_chain(root, target, references, reference_chain) } @@ -1287,666 +1292,65 @@ fn resolve_fragment<'a>(root: &'a Value, fragment: &str) -> Option<&'a Value> { if fragment.starts_with('/') { return root.pointer(fragment); } - find_anchor(root, fragment) + find_anchor(root, fragment, true) } -fn find_anchor<'a>(schema: &'a Value, anchor: &str) -> Option<&'a Value> { +fn find_anchor<'a>(schema: &'a Value, anchor: &str, resource_root: bool) -> Option<&'a Value> { match schema { Value::Object(object) => { + if !resource_root && establishes_schema_resource(object) { + return None; + } if object.get("$anchor").and_then(Value::as_str) == Some(anchor) || object.get("$id").and_then(Value::as_str) == Some(&format!("#{anchor}")) { return Some(schema); } - object.values().find_map(|child| find_anchor(child, anchor)) + object + .values() + .find_map(|child| find_anchor(child, anchor, false)) } - Value::Array(values) => values.iter().find_map(|child| find_anchor(child, anchor)), + Value::Array(values) => values + .iter() + .find_map(|child| find_anchor(child, anchor, false)), _ => None, } } -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] -enum SecretSegment { - Property(String), - Any, - Pattern(SecretPropertyPattern), - UnmatchedProperties(SecretUnmatchedProperties), - Index(usize), - Tail(usize), -} - -#[derive(Debug, Clone)] -struct SecretPropertyPattern { - source: String, - matcher: regex::Regex, -} - -impl PartialEq for SecretPropertyPattern { - fn eq(&self, other: &Self) -> bool { - self.source == other.source - } -} - -impl Eq for SecretPropertyPattern {} - -impl PartialOrd for SecretPropertyPattern { - fn partial_cmp(&self, other: &Self) -> Option { - Some(self.cmp(other)) - } -} - -impl Ord for SecretPropertyPattern { - fn cmp(&self, other: &Self) -> Ordering { - self.source.cmp(&other.source) - } -} - -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] -struct SecretUnmatchedProperties { - properties: Vec, - patterns: Vec, -} - -impl SecretUnmatchedProperties { - fn matches(&self, property: &str) -> bool { - self.properties - .binary_search_by(|candidate| candidate.as_str().cmp(property)) - .is_err() - && !self - .patterns - .iter() - .any(|pattern| pattern_matches(pattern, property)) - } -} - -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] -struct SecretPattern(Vec); - -impl SecretPattern { - fn redact(&self, value: &mut Value, offset: usize) { - self.visit_matching_values(value, offset, &mut |value| { - // A configuration can contain a schema-invalid value before validation. Once the - // schema marks this path as secret, its runtime type must not determine whether it - // is safe to display. Null remains visible because it represents an unset nullable - // secret and carries no payload. - if !value.is_null() { - *value = Value::String(REDACTED.to_owned()); - } - }); - } - - fn redact_for_edit( - &self, - value: &mut Value, - offset: usize, - secrets: &mut SecretEditValues, - occupied: &HashSet, - next_token: &mut usize, - ) { - self.visit_matching_values(value, offset, &mut |value| { - // Tokenize invalid values too, both to keep raw editing safe and to preserve the - // original value if the user leaves it unchanged. - if value.is_null() - || value - .as_str() - .is_some_and(|candidate| secrets.contains_key(candidate)) - { - return; - } - let token = next_secret_token(secrets, occupied, next_token); - secrets.insert( - token.clone(), - SecretEditValue { - value: value.clone(), - pattern: self.clone(), - }, - ); - *value = Value::String(token); - }); - } - - fn visit_matching_values( - &self, - value: &mut Value, - offset: usize, - visit: &mut impl FnMut(&mut Value), - ) { - if offset == self.0.len() { - visit(value); - return; - } - match &self.0[offset] { - SecretSegment::Property(property) => { - if let Some(child) = value.get_mut(property) { - self.visit_matching_values(child, offset + 1, visit); - } - } - SecretSegment::Any => match value { - Value::Object(object) => { - for child in object.values_mut() { - self.visit_matching_values(child, offset + 1, visit); - } - } - Value::Array(values) => { - for child in values { - self.visit_matching_values(child, offset + 1, visit); - } - } - _ => {} - }, - SecretSegment::Pattern(pattern) => { - if let Value::Object(object) = value { - for (key, child) in object { - if pattern_matches(pattern, key) { - self.visit_matching_values(child, offset + 1, visit); - } - } - } - } - SecretSegment::UnmatchedProperties(selector) => { - if let Value::Object(object) = value { - for (key, child) in object { - if selector.matches(key) { - self.visit_matching_values(child, offset + 1, visit); - } - } - } - } - SecretSegment::Index(index) => { - if let Some(child) = value.get_mut(*index) { - self.visit_matching_values(child, offset + 1, visit); - } - } - SecretSegment::Tail(start) => { - if let Value::Array(values) = value { - for child in values.iter_mut().skip(*start) { - self.visit_matching_values(child, offset + 1, visit); - } - } - } - } - } - - fn applies_below(&self, path: &[String]) -> bool { - self.0.len() >= path.len() - && self - .0 - .iter() - .zip(path) - .all(|(segment, property)| match segment { - SecretSegment::Property(expected) => expected == property, - SecretSegment::Any => true, - SecretSegment::Pattern(pattern) => pattern_matches(pattern, property), - SecretSegment::UnmatchedProperties(selector) => selector.matches(property), - SecretSegment::Index(index) => property.parse::() == Ok(*index), - SecretSegment::Tail(start) => { - property.parse::().is_ok_and(|index| index >= *start) - } - }) - } - - fn matches_instance_path(&self, path: &[SecretInstanceSegment]) -> bool { - self.0.len() == path.len() - && self - .0 - .iter() - .zip(path) - .all(|(pattern, instance)| match (pattern, instance) { - ( - SecretSegment::Property(expected), - SecretInstanceSegment::Property(actual), - ) => expected == actual, - (SecretSegment::Any, _) => true, - (SecretSegment::Pattern(pattern), SecretInstanceSegment::Property(actual)) => { - pattern_matches(pattern, actual) - } - ( - SecretSegment::UnmatchedProperties(selector), - SecretInstanceSegment::Property(actual), - ) => selector.matches(actual), - (SecretSegment::Index(expected), SecretInstanceSegment::Index(actual)) => { - expected == actual - } - (SecretSegment::Tail(start), SecretInstanceSegment::Index(actual)) => { - actual >= start - } - _ => false, - }) - } -} - -#[derive(Debug, Clone)] -enum SecretInstanceSegment { - Property(String), - Index(usize), -} - -fn pattern_matches(pattern: &SecretPropertyPattern, property: &str) -> bool { - pattern.matcher.is_match(property) -} - -fn collect_string_values(value: &Value, output: &mut HashSet) { - match value { - Value::String(value) => { - output.insert(value.clone()); - } - Value::Array(values) => { - for value in values { - collect_string_values(value, output); - } - } - Value::Object(values) => { - for value in values.values() { - collect_string_values(value, output); - } - } - _ => {} - } -} - -fn next_secret_token( - secrets: &SecretEditValues, - occupied: &HashSet, - next_token: &mut usize, -) -> String { - loop { - let token = format!("{EDIT_REDACTED_PREFIX}{}>", *next_token); - *next_token += 1; - if !secrets.contains_key(&token) && !occupied.contains(&token) { - return token; - } - } -} - -fn restore_secret_tokens(value: &Value, secrets: &SecretEditValues) -> Result { - fn restore( - value: &Value, - secrets: &SecretEditValues, - path: &mut Vec, - used_tokens: &mut HashSet, - ) -> Result { - match value { - Value::String(value) => match secrets.get(value) { - None => Ok(Value::String(value.clone())), - Some(secret) if !secret.pattern.matches_instance_path(path) => Err(format!( - "token '{value}' may only appear at its original schema-declared secret location" - )), - Some(_) if !used_tokens.insert(value.clone()) => { - Err(format!("token '{value}' may only appear once")) - } - Some(secret) => Ok(secret.value.clone()), - }, - Value::Array(values) => { - let mut restored = Vec::with_capacity(values.len()); - for (index, value) in values.iter().enumerate() { - path.push(SecretInstanceSegment::Index(index)); - restored.push(restore(value, secrets, path, used_tokens)?); - path.pop(); - } - Ok(Value::Array(restored)) - } - Value::Object(values) => { - let mut restored = Map::with_capacity(values.len()); - for (key, value) in values { - path.push(SecretInstanceSegment::Property(key.clone())); - restored.insert(key.clone(), restore(value, secrets, path, used_tokens)?); - path.pop(); - } - Ok(Value::Object(restored)) - } - value => Ok(value.clone()), - } - } - - restore(value, secrets, &mut Vec::new(), &mut HashSet::new()) -} - -fn discover_secret_patterns( - root: &Value, - schema: &Value, - instance_path: &[SecretSegment], - reference_stack: &HashSet, - output: &mut Vec, -) -> Result<(), String> { - let mut references = reference_stack.clone(); - let mut reference_chain = Vec::new(); - resolve_schema_chain(root, schema, &mut references, &mut reference_chain) - .map_err(|error| format!("secret schema reference could not be resolved: {error}"))?; - if classify_write_only_chain(&reference_chain)? { - output.push(SecretPattern(instance_path.to_vec())); - return Ok(()); - } - - // Draft 2020-12 treats `$ref` as an applicator, so sibling keywords remain active. Walk - // every node recorded during resolution instead of only the final target; otherwise a - // sibling `properties` subtree can contain writeOnly fields that never get redacted. - for effective_schema in reference_chain { - if let Some(object) = effective_schema.as_object() { - discover_secret_patterns_in_object(root, object, instance_path, &references, output)?; - } - } - Ok(()) -} - -fn discover_secret_patterns_in_object( - root: &Value, - object: &Map, - instance_path: &[SecretSegment], - references: &HashSet, - output: &mut Vec, -) -> Result<(), String> { - let properties = object.get("properties").and_then(Value::as_object); - discover_named_secret_patterns(root, properties, instance_path, references, output)?; - let pattern_schemas = collect_secret_pattern_schemas(object)?; - discover_additional_secret_patterns( - root, - object, - properties, - &pattern_schemas, - instance_path, - references, - output, - )?; - discover_pattern_property_secret_patterns( - root, - pattern_schemas, - instance_path, - references, - output, - )?; - discover_item_secret_patterns(root, object, instance_path, references, output)?; - discover_prefix_item_secret_patterns(root, object, instance_path, references, output)?; - discover_all_of_secret_patterns(root, object, instance_path, references, output)?; - reject_array_applicator_secret_patterns(root, object, instance_path, references)?; - reject_value_applicator_secret_patterns( - root, - object, - &["if", "then", "else", "not"], - instance_path, - references, - )?; - discover_contains_secret_patterns(root, object, instance_path, references, output)?; - reject_value_applicator_secret_patterns( - root, - object, - &["unevaluatedProperties", "unevaluatedItems"], - instance_path, - references, - )?; - reject_object_applicator_secret_patterns( - root, - object, - &["dependentSchemas", "dependencies"], - instance_path, - references, - )?; - Ok(()) -} - -fn discover_named_secret_patterns( - root: &Value, - properties: Option<&Map>, - instance_path: &[SecretSegment], - references: &HashSet, - output: &mut Vec, -) -> Result<(), String> { - let Some(properties) = properties else { - return Ok(()); - }; - for (property, child_schema) in properties { - let mut child_path = instance_path.to_vec(); - child_path.push(SecretSegment::Property(property.clone())); - discover_secret_patterns(root, child_schema, &child_path, references, output)?; - } - Ok(()) -} - -fn collect_secret_pattern_schemas( - object: &Map, -) -> Result, String> { - let Some(patterns) = object.get("patternProperties").and_then(Value::as_object) else { - return Ok(Vec::new()); - }; - let mut pattern_schemas = Vec::new(); - for (pattern, child_schema) in patterns { - let matcher = regex::Regex::new(pattern).map_err(|error| { - format!("unsupported patternProperties expression {pattern:?}: {error}") - })?; - pattern_schemas.push(( - SecretPropertyPattern { - source: pattern.clone(), - matcher, - }, - child_schema, - )); - } - pattern_schemas.sort_by(|(left, _), (right, _)| left.cmp(right)); - Ok(pattern_schemas) -} - -fn discover_additional_secret_patterns( - root: &Value, - object: &Map, - properties: Option<&Map>, - pattern_schemas: &[(SecretPropertyPattern, &Value)], - instance_path: &[SecretSegment], - references: &HashSet, - output: &mut Vec, -) -> Result<(), String> { - let Some(additional) = object.get("additionalProperties") else { - return Ok(()); - }; - if !additional.is_object() { - return Ok(()); - } - let mut excluded_properties = properties - .into_iter() - .flat_map(|properties| properties.keys().cloned()) - .collect::>(); - excluded_properties.sort(); - let mut child_path = instance_path.to_vec(); - child_path.push(SecretSegment::UnmatchedProperties( - SecretUnmatchedProperties { - properties: excluded_properties, - patterns: pattern_schemas - .iter() - .map(|(pattern, _)| pattern.clone()) - .collect(), - }, - )); - discover_secret_patterns(root, additional, &child_path, references, output) -} - -fn discover_pattern_property_secret_patterns( - root: &Value, - pattern_schemas: Vec<(SecretPropertyPattern, &Value)>, - instance_path: &[SecretSegment], - references: &HashSet, - output: &mut Vec, -) -> Result<(), String> { - for (pattern, child_schema) in pattern_schemas { - let mut child_path = instance_path.to_vec(); - child_path.push(SecretSegment::Pattern(pattern)); - discover_secret_patterns(root, child_schema, &child_path, references, output)?; - } - Ok(()) -} - -fn discover_item_secret_patterns( - root: &Value, - object: &Map, - instance_path: &[SecretSegment], - references: &HashSet, - output: &mut Vec, -) -> Result<(), String> { - let Some(items) = object.get("items") else { - return Ok(()); - }; - if items.is_object() { - let mut child_path = instance_path.to_vec(); - let segment = object - .get("prefixItems") - .and_then(Value::as_array) - .map_or(SecretSegment::Any, |prefix_items| { - SecretSegment::Tail(prefix_items.len()) - }); - child_path.push(segment); - return discover_secret_patterns(root, items, &child_path, references, output); - } - let Some(tuple_items) = items.as_array() else { - return Ok(()); - }; - for (index, child_schema) in tuple_items.iter().enumerate() { - let mut child_path = instance_path.to_vec(); - child_path.push(SecretSegment::Index(index)); - discover_secret_patterns(root, child_schema, &child_path, references, output)?; - } - if let Some(additional_items) = object.get("additionalItems") - && additional_items.is_object() - { - let mut child_path = instance_path.to_vec(); - child_path.push(SecretSegment::Tail(tuple_items.len())); - discover_secret_patterns(root, additional_items, &child_path, references, output)?; - } - Ok(()) -} - -fn discover_prefix_item_secret_patterns( - root: &Value, - object: &Map, - instance_path: &[SecretSegment], - references: &HashSet, - output: &mut Vec, -) -> Result<(), String> { - let Some(prefix_items) = object.get("prefixItems").and_then(Value::as_array) else { - return Ok(()); - }; - for (index, child_schema) in prefix_items.iter().enumerate() { - let mut child_path = instance_path.to_vec(); - child_path.push(SecretSegment::Index(index)); - discover_secret_patterns(root, child_schema, &child_path, references, output)?; - } - Ok(()) -} - -fn discover_all_of_secret_patterns( - root: &Value, - object: &Map, - instance_path: &[SecretSegment], - references: &HashSet, - output: &mut Vec, -) -> Result<(), String> { - let Some(branches) = object.get("allOf").and_then(Value::as_array) else { - return Ok(()); - }; - for branch in branches { - discover_secret_patterns(root, branch, instance_path, references, output)?; - } - Ok(()) -} - -fn reject_array_applicator_secret_patterns( - root: &Value, - object: &Map, - instance_path: &[SecretSegment], - references: &HashSet, -) -> Result<(), String> { - for keyword in ["anyOf", "oneOf"] { - let Some(branches) = object.get(keyword).and_then(Value::as_array) else { - continue; +fn active_schema_resource<'a>(root: &'a Value, target: &Value) -> Option<&'a Value> { + fn locate<'a>( + current: &'a Value, + target: *const Value, + active: &'a Value, + ) -> Option<&'a Value> { + let active = match current { + Value::Object(object) if establishes_schema_resource(object) => current, + _ => active, }; - for branch in branches { - reject_write_only_under_applicator(root, keyword, branch, instance_path, references)?; - } - } - Ok(()) -} - -fn reject_value_applicator_secret_patterns( - root: &Value, - object: &Map, - keywords: &[&str], - instance_path: &[SecretSegment], - references: &HashSet, -) -> Result<(), String> { - for keyword in keywords { - if let Some(branch) = object.get(*keyword) - && branch.is_object() - { - reject_write_only_under_applicator(root, keyword, branch, instance_path, references)?; + if std::ptr::eq(current, target) { + return Some(active); } - } - Ok(()) -} - -fn discover_contains_secret_patterns( - root: &Value, - object: &Map, - instance_path: &[SecretSegment], - references: &HashSet, - output: &mut Vec, -) -> Result<(), String> { - let Some(contains) = object.get("contains") else { - return Ok(()); - }; - if !contains.is_object() { - return Ok(()); - } - let mut child_path = instance_path.to_vec(); - child_path.push(SecretSegment::Any); - discover_secret_patterns(root, contains, &child_path, references, output) -} - -fn reject_object_applicator_secret_patterns( - root: &Value, - object: &Map, - keywords: &[&str], - instance_path: &[SecretSegment], - references: &HashSet, -) -> Result<(), String> { - for keyword in keywords { - let Some(branches) = object.get(*keyword).and_then(Value::as_object) else { - continue; - }; - for branch in branches.values().filter(|branch| branch.is_object()) { - reject_write_only_under_applicator(root, keyword, branch, instance_path, references)?; + match current { + Value::Object(object) => object + .values() + .find_map(|child| locate(child, target, active)), + Value::Array(values) => values + .iter() + .find_map(|child| locate(child, target, active)), + _ => None, } } - Ok(()) -} - -fn reject_write_only_under_applicator( - root: &Value, - keyword: &str, - schema: &Value, - instance_path: &[SecretSegment], - references: &HashSet, -) -> Result<(), String> { - let mut nested_patterns = Vec::new(); - discover_secret_patterns( - root, - schema, - instance_path, - references, - &mut nested_patterns, - )?; - if nested_patterns.is_empty() { - Ok(()) - } else { - Err(format!( - "writeOnly fields under '{keyword}' are not supported for secret redaction" - )) - } -} -fn push_pointer(pointer: &str, segment: &str) -> String { - format!("{pointer}/{}", escape_pointer(segment)) + locate(root, std::ptr::from_ref(target), root) } -fn escape_pointer(segment: &str) -> String { - segment.replace('~', "~0").replace('/', "~1") +fn establishes_schema_resource(object: &Map) -> bool { + object + .get("$id") + .and_then(Value::as_str) + .is_some_and(|id| !id.starts_with('#')) } #[cfg(test)] -#[path = "../../tests/coverage/plugins_schema_tests.rs"] +#[path = "../../../tests/coverage/shared/plugins_schema_tests.rs"] mod tests; diff --git a/crates/cli/src/plugins/schema/secrets.rs b/crates/cli/src/plugins/schema/secrets.rs new file mode 100644 index 000000000..af608cadc --- /dev/null +++ b/crates/cli/src/plugins/schema/secrets.rs @@ -0,0 +1,651 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Write-only secret discovery, redaction, and edit-token restoration. + +use super::*; + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub(super) enum SecretSegment { + Property(String), + Any, + Pattern(SecretPropertyPattern), + UnmatchedProperties(SecretUnmatchedProperties), + Index(usize), + Tail(usize), +} + +#[derive(Debug, Clone)] +pub(super) struct SecretPropertyPattern { + pub(super) source: String, + matcher: regex::Regex, +} + +impl PartialEq for SecretPropertyPattern { + fn eq(&self, other: &Self) -> bool { + self.source == other.source + } +} + +impl Eq for SecretPropertyPattern {} + +impl PartialOrd for SecretPropertyPattern { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for SecretPropertyPattern { + fn cmp(&self, other: &Self) -> Ordering { + self.source.cmp(&other.source) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub(super) struct SecretUnmatchedProperties { + properties: Vec, + patterns: Vec, +} + +impl SecretUnmatchedProperties { + pub(super) fn matches(&self, property: &str) -> bool { + self.properties + .binary_search_by(|candidate| candidate.as_str().cmp(property)) + .is_err() + && !self + .patterns + .iter() + .any(|pattern| pattern_matches(pattern, property)) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub(super) struct SecretPattern(pub(super) Vec); + +impl SecretPattern { + pub(super) fn redact(&self, value: &mut Value, offset: usize) { + self.visit_matching_values(value, offset, &mut |value| { + // A configuration can contain a schema-invalid value before validation. Once the + // schema marks this path as secret, its runtime type must not determine whether it + // is safe to display. Null remains visible because it represents an unset nullable + // secret and carries no payload. + if !value.is_null() { + *value = Value::String(REDACTED.to_owned()); + } + }); + } + + pub(super) fn redact_for_edit( + &self, + value: &mut Value, + offset: usize, + secrets: &mut SecretEditValues, + occupied: &HashSet, + next_token: &mut usize, + ) { + self.visit_matching_values(value, offset, &mut |value| { + // Tokenize invalid values too, both to keep raw editing safe and to preserve the + // original value if the user leaves it unchanged. + if value.is_null() + || value + .as_str() + .is_some_and(|candidate| secrets.contains_key(candidate)) + { + return; + } + let token = next_secret_token(secrets, occupied, next_token); + secrets.insert( + token.clone(), + SecretEditValue { + value: value.clone(), + pattern: self.clone(), + }, + ); + *value = Value::String(token); + }); + } + + pub(super) fn visit_matching_values( + &self, + value: &mut Value, + offset: usize, + visit: &mut impl FnMut(&mut Value), + ) { + if offset == self.0.len() { + visit(value); + return; + } + match &self.0[offset] { + SecretSegment::Property(property) => { + if let Some(child) = value.get_mut(property) { + self.visit_matching_values(child, offset + 1, visit); + } + } + SecretSegment::Any => match value { + Value::Object(object) => { + for child in object.values_mut() { + self.visit_matching_values(child, offset + 1, visit); + } + } + Value::Array(values) => { + for child in values { + self.visit_matching_values(child, offset + 1, visit); + } + } + _ => {} + }, + SecretSegment::Pattern(pattern) => { + if let Value::Object(object) = value { + for (key, child) in object { + if pattern_matches(pattern, key) { + self.visit_matching_values(child, offset + 1, visit); + } + } + } + } + SecretSegment::UnmatchedProperties(selector) => { + if let Value::Object(object) = value { + for (key, child) in object { + if selector.matches(key) { + self.visit_matching_values(child, offset + 1, visit); + } + } + } + } + SecretSegment::Index(index) => { + if let Some(child) = value.get_mut(*index) { + self.visit_matching_values(child, offset + 1, visit); + } + } + SecretSegment::Tail(start) => { + if let Value::Array(values) = value { + for child in values.iter_mut().skip(*start) { + self.visit_matching_values(child, offset + 1, visit); + } + } + } + } + } + + pub(super) fn applies_below(&self, path: &[String]) -> bool { + self.0.len() >= path.len() + && self + .0 + .iter() + .zip(path) + .all(|(segment, property)| match segment { + SecretSegment::Property(expected) => expected == property, + SecretSegment::Any => true, + SecretSegment::Pattern(pattern) => pattern_matches(pattern, property), + SecretSegment::UnmatchedProperties(selector) => selector.matches(property), + SecretSegment::Index(index) => property.parse::() == Ok(*index), + SecretSegment::Tail(start) => { + property.parse::().is_ok_and(|index| index >= *start) + } + }) + } + + pub(super) fn matches_instance_path(&self, path: &[SecretInstanceSegment]) -> bool { + self.0.len() == path.len() + && self + .0 + .iter() + .zip(path) + .all(|(pattern, instance)| match (pattern, instance) { + ( + SecretSegment::Property(expected), + SecretInstanceSegment::Property(actual), + ) => expected == actual, + (SecretSegment::Any, _) => true, + (SecretSegment::Pattern(pattern), SecretInstanceSegment::Property(actual)) => { + pattern_matches(pattern, actual) + } + ( + SecretSegment::UnmatchedProperties(selector), + SecretInstanceSegment::Property(actual), + ) => selector.matches(actual), + (SecretSegment::Index(expected), SecretInstanceSegment::Index(actual)) => { + expected == actual + } + (SecretSegment::Tail(start), SecretInstanceSegment::Index(actual)) => { + actual >= start + } + _ => false, + }) + } +} + +#[derive(Debug, Clone)] +pub(super) enum SecretInstanceSegment { + Property(String), + Index(usize), +} + +pub(super) fn pattern_matches(pattern: &SecretPropertyPattern, property: &str) -> bool { + pattern.matcher.is_match(property) +} + +pub(super) fn collect_string_values(value: &Value, output: &mut HashSet) { + match value { + Value::String(value) => { + output.insert(value.clone()); + } + Value::Array(values) => { + for value in values { + collect_string_values(value, output); + } + } + Value::Object(values) => { + for value in values.values() { + collect_string_values(value, output); + } + } + _ => {} + } +} + +pub(super) fn next_secret_token( + secrets: &SecretEditValues, + occupied: &HashSet, + next_token: &mut usize, +) -> String { + loop { + let token = format!("{EDIT_REDACTED_PREFIX}{}>", *next_token); + *next_token += 1; + if !secrets.contains_key(&token) && !occupied.contains(&token) { + return token; + } + } +} + +pub(super) fn restore_secret_tokens( + value: &Value, + secrets: &SecretEditValues, +) -> Result { + pub(super) fn restore( + value: &Value, + secrets: &SecretEditValues, + path: &mut Vec, + used_tokens: &mut HashSet, + ) -> Result { + match value { + Value::String(value) => match secrets.get(value) { + None => Ok(Value::String(value.clone())), + Some(secret) if !secret.pattern.matches_instance_path(path) => Err(format!( + "token '{value}' may only appear at its original schema-declared secret location" + )), + Some(_) if !used_tokens.insert(value.clone()) => { + Err(format!("token '{value}' may only appear once")) + } + Some(secret) => Ok(secret.value.clone()), + }, + Value::Array(values) => { + let mut restored = Vec::with_capacity(values.len()); + for (index, value) in values.iter().enumerate() { + path.push(SecretInstanceSegment::Index(index)); + restored.push(restore(value, secrets, path, used_tokens)?); + path.pop(); + } + Ok(Value::Array(restored)) + } + Value::Object(values) => { + let mut restored = Map::with_capacity(values.len()); + for (key, value) in values { + path.push(SecretInstanceSegment::Property(key.clone())); + restored.insert(key.clone(), restore(value, secrets, path, used_tokens)?); + path.pop(); + } + Ok(Value::Object(restored)) + } + value => Ok(value.clone()), + } + } + + restore(value, secrets, &mut Vec::new(), &mut HashSet::new()) +} + +pub(super) fn discover_secret_patterns( + root: &Value, + schema: &Value, + instance_path: &[SecretSegment], + reference_stack: &HashSet, + output: &mut Vec, +) -> Result<(), String> { + let mut references = reference_stack.clone(); + let mut reference_chain = Vec::new(); + resolve_schema_chain(root, schema, &mut references, &mut reference_chain) + .map_err(|error| format!("secret schema reference could not be resolved: {error}"))?; + if classify_write_only_chain(&reference_chain)? { + output.push(SecretPattern(instance_path.to_vec())); + return Ok(()); + } + + // Draft 2020-12 treats `$ref` as an applicator, so sibling keywords remain active. Walk + // every node recorded during resolution instead of only the final target; otherwise a + // sibling `properties` subtree can contain writeOnly fields that never get redacted. + for effective_schema in reference_chain { + if let Some(object) = effective_schema.as_object() { + discover_secret_patterns_in_object(root, object, instance_path, &references, output)?; + } + } + Ok(()) +} + +pub(super) fn discover_secret_patterns_in_object( + root: &Value, + object: &Map, + instance_path: &[SecretSegment], + references: &HashSet, + output: &mut Vec, +) -> Result<(), String> { + let properties = object.get("properties").and_then(Value::as_object); + discover_named_secret_patterns(root, properties, instance_path, references, output)?; + let pattern_schemas = collect_secret_pattern_schemas(object)?; + discover_additional_secret_patterns( + root, + object, + properties, + &pattern_schemas, + instance_path, + references, + output, + )?; + discover_pattern_property_secret_patterns( + root, + pattern_schemas, + instance_path, + references, + output, + )?; + discover_item_secret_patterns(root, object, instance_path, references, output)?; + discover_prefix_item_secret_patterns(root, object, instance_path, references, output)?; + discover_all_of_secret_patterns(root, object, instance_path, references, output)?; + reject_array_applicator_secret_patterns(root, object, instance_path, references)?; + reject_value_applicator_secret_patterns( + root, + object, + &["if", "then", "else", "not"], + instance_path, + references, + )?; + discover_contains_secret_patterns(root, object, instance_path, references, output)?; + reject_value_applicator_secret_patterns( + root, + object, + &["unevaluatedProperties", "unevaluatedItems"], + instance_path, + references, + )?; + reject_object_applicator_secret_patterns( + root, + object, + &["dependentSchemas", "dependencies"], + instance_path, + references, + )?; + Ok(()) +} + +pub(super) fn discover_named_secret_patterns( + root: &Value, + properties: Option<&Map>, + instance_path: &[SecretSegment], + references: &HashSet, + output: &mut Vec, +) -> Result<(), String> { + let Some(properties) = properties else { + return Ok(()); + }; + for (property, child_schema) in properties { + let mut child_path = instance_path.to_vec(); + child_path.push(SecretSegment::Property(property.clone())); + discover_secret_patterns(root, child_schema, &child_path, references, output)?; + } + Ok(()) +} + +pub(super) fn collect_secret_pattern_schemas( + object: &Map, +) -> Result, String> { + let Some(patterns) = object.get("patternProperties").and_then(Value::as_object) else { + return Ok(Vec::new()); + }; + let mut pattern_schemas = Vec::new(); + for (pattern, child_schema) in patterns { + let matcher = regex::Regex::new(pattern).map_err(|error| { + format!("unsupported patternProperties expression {pattern:?}: {error}") + })?; + pattern_schemas.push(( + SecretPropertyPattern { + source: pattern.clone(), + matcher, + }, + child_schema, + )); + } + pattern_schemas.sort_by(|(left, _), (right, _)| left.cmp(right)); + Ok(pattern_schemas) +} + +pub(super) fn discover_additional_secret_patterns( + root: &Value, + object: &Map, + properties: Option<&Map>, + pattern_schemas: &[(SecretPropertyPattern, &Value)], + instance_path: &[SecretSegment], + references: &HashSet, + output: &mut Vec, +) -> Result<(), String> { + let Some(additional) = object.get("additionalProperties") else { + return Ok(()); + }; + if !additional.is_object() { + return Ok(()); + } + let mut excluded_properties = properties + .into_iter() + .flat_map(|properties| properties.keys().cloned()) + .collect::>(); + excluded_properties.sort(); + let mut child_path = instance_path.to_vec(); + child_path.push(SecretSegment::UnmatchedProperties( + SecretUnmatchedProperties { + properties: excluded_properties, + patterns: pattern_schemas + .iter() + .map(|(pattern, _)| pattern.clone()) + .collect(), + }, + )); + discover_secret_patterns(root, additional, &child_path, references, output) +} + +pub(super) fn discover_pattern_property_secret_patterns( + root: &Value, + pattern_schemas: Vec<(SecretPropertyPattern, &Value)>, + instance_path: &[SecretSegment], + references: &HashSet, + output: &mut Vec, +) -> Result<(), String> { + for (pattern, child_schema) in pattern_schemas { + let mut child_path = instance_path.to_vec(); + child_path.push(SecretSegment::Pattern(pattern)); + discover_secret_patterns(root, child_schema, &child_path, references, output)?; + } + Ok(()) +} + +pub(super) fn discover_item_secret_patterns( + root: &Value, + object: &Map, + instance_path: &[SecretSegment], + references: &HashSet, + output: &mut Vec, +) -> Result<(), String> { + let Some(items) = object.get("items") else { + return Ok(()); + }; + if items.is_object() { + let mut child_path = instance_path.to_vec(); + let segment = object + .get("prefixItems") + .and_then(Value::as_array) + .map_or(SecretSegment::Any, |prefix_items| { + SecretSegment::Tail(prefix_items.len()) + }); + child_path.push(segment); + return discover_secret_patterns(root, items, &child_path, references, output); + } + let Some(tuple_items) = items.as_array() else { + return Ok(()); + }; + for (index, child_schema) in tuple_items.iter().enumerate() { + let mut child_path = instance_path.to_vec(); + child_path.push(SecretSegment::Index(index)); + discover_secret_patterns(root, child_schema, &child_path, references, output)?; + } + if let Some(additional_items) = object.get("additionalItems") + && additional_items.is_object() + { + let mut child_path = instance_path.to_vec(); + child_path.push(SecretSegment::Tail(tuple_items.len())); + discover_secret_patterns(root, additional_items, &child_path, references, output)?; + } + Ok(()) +} + +pub(super) fn discover_prefix_item_secret_patterns( + root: &Value, + object: &Map, + instance_path: &[SecretSegment], + references: &HashSet, + output: &mut Vec, +) -> Result<(), String> { + let Some(prefix_items) = object.get("prefixItems").and_then(Value::as_array) else { + return Ok(()); + }; + for (index, child_schema) in prefix_items.iter().enumerate() { + let mut child_path = instance_path.to_vec(); + child_path.push(SecretSegment::Index(index)); + discover_secret_patterns(root, child_schema, &child_path, references, output)?; + } + Ok(()) +} + +pub(super) fn discover_all_of_secret_patterns( + root: &Value, + object: &Map, + instance_path: &[SecretSegment], + references: &HashSet, + output: &mut Vec, +) -> Result<(), String> { + let Some(branches) = object.get("allOf").and_then(Value::as_array) else { + return Ok(()); + }; + for branch in branches { + discover_secret_patterns(root, branch, instance_path, references, output)?; + } + Ok(()) +} + +pub(super) fn reject_array_applicator_secret_patterns( + root: &Value, + object: &Map, + instance_path: &[SecretSegment], + references: &HashSet, +) -> Result<(), String> { + for keyword in ["anyOf", "oneOf"] { + let Some(branches) = object.get(keyword).and_then(Value::as_array) else { + continue; + }; + for branch in branches { + reject_write_only_under_applicator(root, keyword, branch, instance_path, references)?; + } + } + Ok(()) +} + +pub(super) fn reject_value_applicator_secret_patterns( + root: &Value, + object: &Map, + keywords: &[&str], + instance_path: &[SecretSegment], + references: &HashSet, +) -> Result<(), String> { + for keyword in keywords { + if let Some(branch) = object.get(*keyword) + && branch.is_object() + { + reject_write_only_under_applicator(root, keyword, branch, instance_path, references)?; + } + } + Ok(()) +} + +pub(super) fn discover_contains_secret_patterns( + root: &Value, + object: &Map, + instance_path: &[SecretSegment], + references: &HashSet, + output: &mut Vec, +) -> Result<(), String> { + let Some(contains) = object.get("contains") else { + return Ok(()); + }; + if !contains.is_object() { + return Ok(()); + } + let mut child_path = instance_path.to_vec(); + child_path.push(SecretSegment::Any); + discover_secret_patterns(root, contains, &child_path, references, output) +} + +pub(super) fn reject_object_applicator_secret_patterns( + root: &Value, + object: &Map, + keywords: &[&str], + instance_path: &[SecretSegment], + references: &HashSet, +) -> Result<(), String> { + for keyword in keywords { + let Some(branches) = object.get(*keyword).and_then(Value::as_object) else { + continue; + }; + for branch in branches.values().filter(|branch| branch.is_object()) { + reject_write_only_under_applicator(root, keyword, branch, instance_path, references)?; + } + } + Ok(()) +} + +pub(super) fn reject_write_only_under_applicator( + root: &Value, + keyword: &str, + schema: &Value, + instance_path: &[SecretSegment], + references: &HashSet, +) -> Result<(), String> { + let mut nested_patterns = Vec::new(); + discover_secret_patterns( + root, + schema, + instance_path, + references, + &mut nested_patterns, + )?; + if nested_patterns.is_empty() { + Ok(()) + } else { + Err(format!( + "writeOnly fields under '{keyword}' are not supported for secret redaction" + )) + } +} + +pub(super) fn push_pointer(pointer: &str, segment: &str) -> String { + format!("{pointer}/{}", escape_pointer(segment)) +} + +pub(super) fn escape_pointer(segment: &str) -> String { + segment.replace('~', "~0").replace('/', "~1") +} diff --git a/crates/cli/src/plugins/types.rs b/crates/cli/src/plugins/types.rs new file mode 100644 index 000000000..05f842436 --- /dev/null +++ b/crates/cli/src/plugins/types.rs @@ -0,0 +1,77 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use std::path::PathBuf; + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub(crate) enum ConfigurationScope { + /// No explicit scope flag was supplied. Runtime behavior defaults to the user scope. + #[default] + Default, + User, + Project, + Global, + /// More than one mutually exclusive command scope was supplied. + Invalid, +} + +#[derive(Debug, Clone, Default)] +pub(crate) struct PluginsEditRequest { + pub(crate) scope: ConfigurationScope, +} +#[derive(Debug, Clone, Default)] +pub(crate) struct PluginsAddRequest { + pub(crate) scope: ConfigurationScope, + pub(crate) path: PathBuf, +} +#[derive(Debug, Clone)] +pub(crate) struct PluginsValidateRequest { + pub(crate) target: String, + pub(crate) json: bool, +} +#[derive(Debug, Clone, Default)] +pub(crate) struct PluginsListRequest { + pub(crate) all: bool, + pub(crate) json: bool, +} +#[derive(Debug, Clone)] +pub(crate) struct PluginsInspectRequest { + pub(crate) id: String, + pub(crate) json: bool, +} +#[derive(Debug, Clone)] +pub(crate) struct PluginsEnableRequest { + pub(crate) id: String, +} +#[derive(Debug, Clone)] +pub(crate) struct PluginsDisableRequest { + pub(crate) id: String, +} +#[derive(Debug, Clone)] +pub(crate) struct PluginsRemoveRequest { + pub(crate) id: String, +} + +#[derive(Debug, Clone)] +pub(crate) struct PricingValidateRequest { + pub(crate) path: PathBuf, +} +#[derive(Debug, Clone)] +pub(crate) struct PricingInitRequest { + pub(crate) scope: ConfigurationScope, +} +#[derive(Debug, Clone)] +pub(crate) struct PricingAddSourceRequest { + pub(crate) scope: ConfigurationScope, + pub(crate) path: PathBuf, + pub(crate) append: bool, +} +#[derive(Debug, Clone)] +pub(crate) struct PricingResolveRequest { + pub(crate) model: String, + pub(crate) provider: Option, + pub(crate) prompt_tokens: Option, + pub(crate) completion_tokens: Option, + pub(crate) cache_read_tokens: Option, + pub(crate) cache_write_tokens: Option, +} diff --git a/crates/cli/src/process/detached.rs b/crates/cli/src/process/detached.rs new file mode 100644 index 000000000..9b2cc2ca3 --- /dev/null +++ b/crates/cli/src/process/detached.rs @@ -0,0 +1,387 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Minimal cross-platform process detachment for the shared gateway. + +use std::process::Command; + +#[cfg(not(windows))] +use std::process::Child; + +#[cfg(windows)] +use std::sync::Mutex; + +#[cfg(windows)] +static SIDECAR_SPAWN_LOCK: Mutex<()> = Mutex::new(()); + +#[cfg(not(windows))] +pub(crate) type DetachedChild = Child; + +#[cfg(windows)] +pub(crate) struct DetachedChild { + process: windows_sys::Win32::Foundation::HANDLE, + thread: windows_sys::Win32::Foundation::HANDLE, + id: u32, +} + +#[cfg(windows)] +unsafe impl Send for DetachedChild {} + +#[cfg(windows)] +impl DetachedChild { + pub(crate) fn id(&self) -> u32 { + self.id + } + + pub(crate) fn try_wait(&mut self) -> std::io::Result> { + use std::os::windows::process::ExitStatusExt; + use windows_sys::Win32::Foundation::{WAIT_OBJECT_0, WAIT_TIMEOUT}; + use windows_sys::Win32::System::Threading::{GetExitCodeProcess, WaitForSingleObject}; + + // SAFETY: `process` is owned by this value until Drop. + match unsafe { WaitForSingleObject(self.process, 0) } { + WAIT_TIMEOUT => Ok(None), + WAIT_OBJECT_0 => { + let mut code = 0; + // SAFETY: The process handle and output pointer are valid. + if unsafe { GetExitCodeProcess(self.process, &mut code) } == 0 { + Err(std::io::Error::last_os_error()) + } else { + Ok(Some(std::process::ExitStatus::from_raw(code))) + } + } + _ => Err(std::io::Error::last_os_error()), + } + } + + pub(crate) fn wait(&mut self) -> std::io::Result { + use windows_sys::Win32::Foundation::WAIT_OBJECT_0; + use windows_sys::Win32::System::Threading::{INFINITE, WaitForSingleObject}; + + // SAFETY: `process` is owned by this value until Drop. + if unsafe { WaitForSingleObject(self.process, INFINITE) } != WAIT_OBJECT_0 { + return Err(std::io::Error::last_os_error()); + } + self.try_wait()?.ok_or_else(|| { + std::io::Error::other("detached gateway was still running after a completed wait") + }) + } +} + +#[cfg(windows)] +impl Drop for DetachedChild { + fn drop(&mut self) { + use windows_sys::Win32::Foundation::CloseHandle; + + // SAFETY: Both handles were returned by CreateProcessW and are owned here. + unsafe { + CloseHandle(self.thread); + CloseHandle(self.process); + } + } +} + +#[cfg(windows)] +fn spawn_detached_with_handle_list(command: &Command) -> std::io::Result { + use std::collections::BTreeMap; + use std::ffi::{OsStr, OsString, c_void}; + use std::fs::OpenOptions; + use std::os::windows::ffi::OsStrExt; + use std::os::windows::io::AsRawHandle; + use windows_sys::Win32::Foundation::{HANDLE, HANDLE_FLAG_INHERIT, SetHandleInformation}; + use windows_sys::Win32::System::Threading::{ + CREATE_UNICODE_ENVIRONMENT, CreateProcessW, DeleteProcThreadAttributeList, + EXTENDED_STARTUPINFO_PRESENT, InitializeProcThreadAttributeList, + PROC_THREAD_ATTRIBUTE_HANDLE_LIST, PROCESS_INFORMATION, STARTF_USESTDHANDLES, + STARTUPINFOEXW, UpdateProcThreadAttribute, + }; + + fn wide_nul(value: &OsStr) -> Vec { + value.encode_wide().chain(std::iter::once(0)).collect() + } + + fn append_quoted(command_line: &mut Vec, value: &OsStr) { + const BACKSLASH: u16 = b'\\' as u16; + const QUOTE: u16 = b'"' as u16; + command_line.push(QUOTE); + let mut slashes = 0; + for unit in value.encode_wide() { + if unit == BACKSLASH { + slashes += 1; + continue; + } + if unit == QUOTE { + command_line.extend(std::iter::repeat_n(BACKSLASH, slashes * 2 + 1)); + } else { + command_line.extend(std::iter::repeat_n(BACKSLASH, slashes)); + } + slashes = 0; + command_line.push(unit); + } + command_line.extend(std::iter::repeat_n(BACKSLASH, slashes * 2)); + command_line.push(QUOTE); + } + + fn environment_block(command: &Command) -> Vec { + let mut environment = BTreeMap::::new(); + for (name, value) in std::env::vars_os() { + environment.insert(name.to_string_lossy().to_uppercase(), (name, value)); + } + for (name, value) in command.get_envs() { + let key = name.to_string_lossy().to_uppercase(); + if let Some(value) = value { + environment.insert(key, (name.to_owned(), value.to_owned())); + } else { + environment.remove(&key); + } + } + let mut block = Vec::new(); + for (_, (name, value)) in environment { + block.extend(name.encode_wide()); + block.push(b'=' as u16); + block.extend(value.encode_wide()); + block.push(0); + } + block.push(0); + block + } + + struct AttributeList(*mut c_void); + impl Drop for AttributeList { + fn drop(&mut self) { + // SAFETY: The list was initialized successfully and is still live. + unsafe { DeleteProcThreadAttributeList(self.0) }; + } + } + + let stdin = OpenOptions::new().read(true).open(r"\\.\NUL")?; + let stdout = OpenOptions::new().write(true).open(r"\\.\NUL")?; + let handles = [ + stdin.as_raw_handle().cast::(), + stdout.as_raw_handle().cast::(), + ]; + for handle in handles { + // SAFETY: These are live handles owned by `stdin` and `stdout`. + if unsafe { SetHandleInformation(handle, HANDLE_FLAG_INHERIT, HANDLE_FLAG_INHERIT) } == 0 { + return Err(std::io::Error::last_os_error()); + } + } + + let mut attribute_bytes = 0; + // SAFETY: A null first call obtains the required allocation size. + unsafe { InitializeProcThreadAttributeList(std::ptr::null_mut(), 1, 0, &mut attribute_bytes) }; + if attribute_bytes == 0 { + return Err(std::io::Error::last_os_error()); + } + let words = attribute_bytes.div_ceil(std::mem::size_of::()); + let mut attribute_storage = vec![0_usize; words]; + let attribute_pointer = attribute_storage.as_mut_ptr().cast::(); + // SAFETY: The aligned allocation has the size returned by the sizing call. + if unsafe { InitializeProcThreadAttributeList(attribute_pointer, 1, 0, &mut attribute_bytes) } + == 0 + { + return Err(std::io::Error::last_os_error()); + } + let attribute_list = AttributeList(attribute_pointer); + // SAFETY: `handles` remains live through CreateProcessW and contains only the intended stdio. + if unsafe { + UpdateProcThreadAttribute( + attribute_list.0, + 0, + PROC_THREAD_ATTRIBUTE_HANDLE_LIST as usize, + handles.as_ptr().cast(), + std::mem::size_of_val(&handles), + std::ptr::null_mut(), + std::ptr::null(), + ) + } == 0 + { + return Err(std::io::Error::last_os_error()); + } + + let program = wide_nul(command.get_program()); + let mut command_line = Vec::new(); + append_quoted(&mut command_line, command.get_program()); + for argument in command.get_args() { + command_line.push(b' ' as u16); + append_quoted(&mut command_line, argument); + } + command_line.push(0); + let environment = environment_block(command); + let current_dir = command + .get_current_dir() + .map(|path| wide_nul(path.as_os_str())); + + let mut startup = STARTUPINFOEXW::default(); + startup.StartupInfo.cb = std::mem::size_of::() as u32; + startup.StartupInfo.dwFlags = STARTF_USESTDHANDLES; + startup.StartupInfo.hStdInput = handles[0] as HANDLE; + startup.StartupInfo.hStdOutput = handles[1] as HANDLE; + startup.StartupInfo.hStdError = handles[1] as HANDLE; + startup.lpAttributeList = attribute_list.0; + let mut process = PROCESS_INFORMATION::default(); + let (in_job, limits) = current_windows_job_limits(); + let (creation_flags, _) = windows_creation_flags(in_job, limits); + // SAFETY: Every pointer references initialized storage that remains live for this call. + let created = unsafe { + CreateProcessW( + program.as_ptr(), + command_line.as_mut_ptr(), + std::ptr::null(), + std::ptr::null(), + 1, + creation_flags | CREATE_UNICODE_ENVIRONMENT | EXTENDED_STARTUPINFO_PRESENT, + environment.as_ptr().cast(), + current_dir + .as_ref() + .map_or(std::ptr::null(), |path| path.as_ptr()), + (&raw const startup).cast(), + &mut process, + ) + }; + let create_error = (created == 0).then(std::io::Error::last_os_error); + for handle in handles { + // SAFETY: The handles remain live; clear inheritance before releasing the spawn lock. + unsafe { SetHandleInformation(handle, HANDLE_FLAG_INHERIT, 0) }; + } + if let Some(error) = create_error { + return Err(error); + } + Ok(DetachedChild { + process: process.hProcess, + thread: process.hThread, + id: process.dwProcessId, + }) +} + +#[cfg(windows)] +pub(crate) fn spawn_detached(command: &mut Command) -> std::io::Result { + let _spawn_guard = SIDECAR_SPAWN_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + spawn_detached_with_handle_list(command) +} + +#[cfg(not(windows))] +pub(crate) fn spawn_detached(command: &mut Command) -> std::io::Result { + command.spawn() +} + +#[cfg(unix)] +pub(crate) fn configure_detached(command: &mut Command) { + use std::os::unix::process::CommandExt; + + // SAFETY: setsid is async-signal-safe and runs in the post-fork child before exec. + unsafe { + command.pre_exec(|| { + if libc::setsid() == -1 { + Err(std::io::Error::last_os_error()) + } else { + Ok(()) + } + }); + } +} + +#[cfg(any(test, windows))] +pub(crate) const WINDOWS_CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200; +#[cfg(any(test, windows))] +pub(crate) const WINDOWS_CREATE_BREAKAWAY_FROM_JOB: u32 = 0x0100_0000; +#[cfg(any(test, windows))] +pub(crate) const WINDOWS_CREATE_NO_WINDOW: u32 = 0x0800_0000; +#[cfg(any(test, windows))] +pub(crate) const WINDOWS_JOB_OBJECT_LIMIT_BREAKAWAY_OK: u32 = 0x0000_0800; +#[cfg(any(test, windows))] +pub(crate) const WINDOWS_JOB_OBJECT_LIMIT_SILENT_BREAKAWAY_OK: u32 = 0x0000_1000; + +#[cfg(any(test, windows))] +pub(crate) fn windows_creation_flags(in_job: bool, job_limit_flags: Option) -> (u32, bool) { + let base = WINDOWS_CREATE_NEW_PROCESS_GROUP | WINDOWS_CREATE_NO_WINDOW; + if !in_job { + return (base, false); + } + match job_limit_flags { + Some(flags) if flags & WINDOWS_JOB_OBJECT_LIMIT_BREAKAWAY_OK != 0 => { + (base | WINDOWS_CREATE_BREAKAWAY_FROM_JOB, false) + } + Some(flags) if flags & WINDOWS_JOB_OBJECT_LIMIT_SILENT_BREAKAWAY_OK != 0 => (base, false), + Some(_) | None => (base, true), + } +} + +#[cfg(windows)] +fn current_windows_job_limits() -> (bool, Option) { + use windows_sys::Win32::System::JobObjects::{ + IsProcessInJob, JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JobObjectExtendedLimitInformation, + QueryInformationJobObject, + }; + use windows_sys::Win32::System::Threading::GetCurrentProcess; + + let mut in_job = 0; + // SAFETY: The pseudo current-process handle and null current-job handle are valid here. + if unsafe { IsProcessInJob(GetCurrentProcess(), std::ptr::null_mut(), &mut in_job) } == 0 { + return (true, None); + } + if in_job == 0 { + return (false, Some(0)); + } + let mut limits = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default(); + // SAFETY: The output buffer matches the requested information class. + let queried = unsafe { + QueryInformationJobObject( + std::ptr::null_mut(), + JobObjectExtendedLimitInformation, + std::ptr::from_mut(&mut limits).cast(), + std::mem::size_of::() as u32, + std::ptr::null_mut(), + ) + }; + if queried == 0 { + (true, None) + } else { + (true, Some(limits.BasicLimitInformation.LimitFlags)) + } +} + +#[cfg(windows)] +pub(crate) fn configure_detached(_command: &mut Command) { + let (in_job, limits) = current_windows_job_limits(); + let (_, limited_lifetime) = windows_creation_flags(in_job, limits); + if limited_lifetime { + eprintln!( + "warning: the current Windows Job Object does not permit process breakaway; the shared Relay gateway lifetime is limited to the host job" + ); + } +} + +#[cfg(not(any(unix, windows)))] +pub(crate) fn configure_detached(_command: &mut Command) {} + +pub(crate) fn terminate_tree(child: &mut DetachedChild) { + #[cfg(unix)] + { + let process_group = -(child.id() as i32); + // SAFETY: Detached gateways call setsid, so the child PID is the process-group ID. + if unsafe { libc::kill(process_group, libc::SIGKILL) } == -1 { + let _ = child.kill(); + } + } + #[cfg(windows)] + { + let status = Command::new("taskkill") + .args(["/PID", &child.id().to_string(), "/T", "/F"]) + .status(); + if !status.is_ok_and(|status| status.success()) { + eprintln!( + "failed to terminate detached gateway process tree {} with taskkill", + child.id() + ); + return; + } + } + #[cfg(not(any(unix, windows)))] + { + let _ = child.kill(); + } + let _ = child.wait(); +} diff --git a/crates/cli/src/process/launcher.rs b/crates/cli/src/process/launcher.rs new file mode 100644 index 000000000..bb918d47b --- /dev/null +++ b/crates/cli/src/process/launcher.rs @@ -0,0 +1,809 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use std::path::{Path, PathBuf}; +use std::process::ExitCode; +use std::time::Duration; + +use nemo_relay::observability::plugin_component::{ + AtifStorageConfig, AtofSinkSectionConfig, OBSERVABILITY_PLUGIN_KIND, ObservabilityConfig, +}; +use nemo_relay::plugin::PluginConfig; +use serde_json::Value; +#[cfg(test)] +use serde_json::json; +use tokio::net::TcpListener; +use tokio::sync::oneshot; +use tokio::task::JoinHandle; + +use crate::agents::CodingAgent; +use crate::configuration::{AgentConfigs, GatewayConfig, ResolvedConfig, resolve_run_config}; +use crate::error::CliError; +use crate::plugins::lifecycle::ActiveDynamicPluginComponent; +use crate::server; +use crate::server::GatewayOverrides; + +use super::{PreparedAgentLaunch, RunOverrides}; + +/// Runs a child coding-agent command behind an ephemeral local gateway. +/// +/// The gateway binds to an OS-assigned loopback port, prepares agent-specific hook/gateway wiring, +/// waits for health before spawning the child, and removes temporary state after the child and +/// server shut down. The child's exit status is preserved when it fits in `ExitCode`; otherwise the +/// launcher reports generic failure. +pub(crate) async fn run( + command: RunOverrides, + inherited: Option<&GatewayOverrides>, +) -> Result { + let run = TransparentRun::new(command, inherited).await?; + run.print_if_requested(); + run.execute().await +} + +struct TransparentRun { + agent: CodingAgent, + prepared: PreparedAgentLaunch, + resolved: ResolvedConfig, + dynamic_plugins: Vec, + listener: TcpListener, + gateway_url: String, + dry_run: bool, + print: bool, +} + +impl TransparentRun { + // Resolves configuration, binds the ephemeral listener, and builds agent-specific launch wiring + // without starting the gateway or spawning the child command. + async fn new( + command: RunOverrides, + inherited: Option<&GatewayOverrides>, + ) -> Result { + let dry_run = command.dry_run; + let print = command.print; + let explicit_config = command + .config + .as_ref() + .or_else(|| inherited.and_then(|args| args.config.as_ref())); + let mut resolved = resolve_run_config(&command, inherited)?; + let dynamic_plugins = if dry_run { + Vec::new() + } else { + crate::plugins::lifecycle::active_dynamic_plugin_components(explicit_config, &resolved)? + }; + let invocation = resolve_agent_invocation(&command, &resolved.agents)?; + let agent = invocation.agent; + if !dry_run { + let probe = crate::process::version_probe_argv( + agent, + &invocation.argv[..=invocation.host_index], + ); + validate_agent_version(agent, &probe).await?; + } + let listener = TcpListener::bind("127.0.0.1:0").await?; + let address = listener.local_addr()?; + let gateway_url = format!("http://{address}"); + resolved.gateway.bind = address; + + let prepared = + PreparedAgentLaunch::from_invocation(invocation, &gateway_url, &resolved, dry_run)?; + Ok(Self { + agent, + prepared, + resolved, + dynamic_plugins, + listener, + gateway_url, + dry_run, + print, + }) + } + + // Emits the resolved run plan when requested. Dry runs always print because inspection is their + // primary behavior; live runs print only when `--print` was passed. + fn print_if_requested(&self) { + if self.print || self.dry_run { + self.prepared + .print(self.agent, &self.gateway_url, &self.resolved); + } + } + + // Runs the prepared child command unless this is an inspection-only dry run. + async fn execute(self) -> Result { + if self.dry_run { + return Ok(ExitCode::SUCCESS); + } + self.prepared + .print_live_status(self.agent, &self.gateway_url, &self.resolved); + execute_live_run_with_dynamic( + self.listener, + self.resolved.gateway, + self.dynamic_plugins, + &self.gateway_url, + self.prepared, + ) + .await + } +} + +// Starts the gateway, waits for readiness, runs the child command, restores temporary state, and then +// maps the child process status to the launcher's exit code. +#[cfg(test)] +async fn execute_live_run( + listener: TcpListener, + gateway_config: GatewayConfig, + gateway_url: &str, + prepared: PreparedAgentLaunch, +) -> Result { + execute_live_run_with_dynamic(listener, gateway_config, Vec::new(), gateway_url, prepared).await +} + +async fn execute_live_run_with_dynamic( + listener: TcpListener, + gateway_config: GatewayConfig, + dynamic_plugins: Vec, + gateway_url: &str, + prepared: PreparedAgentLaunch, +) -> Result { + let bootstrap_fingerprint = crate::configuration::transparent_gateway_fingerprint(gateway_url); + let running_server = RunningGateway::start( + listener, + gateway_config, + dynamic_plugins, + bootstrap_fingerprint.clone(), + ); + if let Err(error) = wait_for_health(gateway_url, &bootstrap_fingerprint).await { + let restore = prepared.restore(); + let server_result = running_server.stop().await; + restore?; + server_result?; + return Err(error); + } + supervise_prepared_run(&prepared, running_server).await +} + +async fn supervise_prepared_run( + prepared: &PreparedAgentLaunch, + mut running_server: RunningGateway, +) -> Result { + let mut child = match prepared.spawn().await { + Ok(child) => child, + Err(error) => { + let restore = prepared.restore(); + let server_result = running_server.stop().await; + restore?; + server_result?; + return Err(error); + } + }; + + tokio::select! { + status = child.wait() => { + let restore = prepared.restore(); + let server_result = running_server.stop().await; + restore?; + server_result?; + Ok(exit_code(status?)) + } + gateway_result = running_server.wait() => { + let child_result = child.terminate().await; + let restore = prepared.restore(); + restore?; + child_result?; + match gateway_result { + Err(error) => Err(error), + Ok(()) => Err(CliError::Launch( + "transparent Relay gateway stopped before the coding agent exited".into(), + )), + } + } + } +} + +// Resolves the launched agent and argv from either an explicit command or a configured per-agent +// command. Agent inference only happens from argv[0] when `--agent` was omitted, so explicit agent +// selection can wrap commands whose executable name is not recognizable. +#[derive(Debug, Clone, PartialEq, Eq)] +struct AgentInvocation { + agent: CodingAgent, + argv: Vec, + host_index: usize, +} + +fn resolve_agent_invocation( + command: &RunOverrides, + agents: &AgentConfigs, +) -> Result { + if let Some(agent) = command.agent { + let mut argv = configured_command(agent, agents) + .unwrap_or_else(|| vec![default_command_for(agent).to_string()]); + let host_index = argv + .iter() + .rposition(|argument| CodingAgent::infer(argument) == Some(agent)) + .unwrap_or(0); + argv.extend(command.command.iter().cloned()); + return Ok(AgentInvocation { + agent, + argv, + host_index, + }); + } + if command.command.is_empty() { + return Err(CliError::Launch( + "missing command; pass -- or --agent with a configured command".into(), + )); + } + let argv = command.command.clone(); + let agent = CodingAgent::infer(&argv[0]).ok_or_else(|| { + CliError::Launch(format!( + "could not infer coding agent from command {:?}; pass --agent claude, --agent codex, or --agent hermes", + argv[0] + )) + })?; + Ok(AgentInvocation { + agent, + argv, + host_index: 0, + }) +} + +#[cfg(test)] +fn resolve_agent_and_argv( + command: &RunOverrides, + agents: &AgentConfigs, +) -> Result<(CodingAgent, Vec), CliError> { + resolve_agent_invocation(command, agents).map(|invocation| (invocation.agent, invocation.argv)) +} + +// Default agent binary names used when no `[agents.] command = "..."` override is in the +// resolved config. Matches the executable on $PATH that the wizard's detection probes for. +const fn default_command_for(agent: CodingAgent) -> &'static str { + agent.executable() +} + +/// Builds a version probe that preserves wrappers such as `npx codex` or `mise exec -- codex`. +/// Opaque wrappers remain supported when their `--version` output identifies the selected host. +async fn validate_agent_version(agent: CodingAgent, probe: &[String]) -> Result<(), CliError> { + let mut command = crate::process::tokio_command(probe); + command + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .kill_on_drop(true); + let output = tokio::time::timeout(Duration::from_secs(5), command.output()) + .await + .map_err(|_| { + CliError::Launch(format!( + "timed out while running version probe {:?} for {}; NeMo Relay requires {}", + probe, + agent.label(), + agent.version_requirement() + )) + })??; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(CliError::Launch(format!( + "version probe {:?} failed with {}{}", + probe, + output.status, + if stderr.trim().is_empty() { + String::new() + } else { + format!(": {}", stderr.trim()) + } + ))); + } + let stdout = String::from_utf8_lossy(&output.stdout); + agent + .validate_version_output(&stdout) + .map(|_| ()) + .map_err(CliError::Launch) +} + +// Splits a configured command string into argv words for run mode. This intentionally uses simple +// whitespace splitting because config command values are a convenience fallback; complex shell +// commands should be passed after `--` by the caller. +fn configured_command(agent: CodingAgent, agents: &AgentConfigs) -> Option> { + let command = crate::agents::config(agent, agents).command.as_ref()?; + let argv = crate::process::command_argv(command); + (!argv.is_empty()).then_some(argv) +} + +struct RunningGateway { + shutdown_tx: oneshot::Sender<()>, + task: JoinHandle>, +} + +impl RunningGateway { + // Starts the gateway listener on a background task and keeps the shutdown sender paired with the + // task handle so health failures and normal exits use identical cleanup semantics. + fn start( + listener: TcpListener, + config: crate::configuration::GatewayConfig, + dynamic_plugins: Vec, + bootstrap_fingerprint: String, + ) -> Self { + let (shutdown_tx, shutdown_rx) = oneshot::channel(); + let task = tokio::spawn(async move { + server::serve_transparent_listener_with_dynamic( + listener, + config, + dynamic_plugins, + bootstrap_fingerprint, + Some(shutdown_rx), + ) + .await + }); + Self { shutdown_tx, task } + } + + async fn wait(&mut self) -> Result<(), CliError> { + (&mut self.task) + .await + .map_err(|error| CliError::Launch(format!("gateway task failed: {error}")))? + } + + // Requests shutdown and joins the server task. The send can fail only if the task already exited; + // the join result still captures whether serving ended cleanly. + async fn stop(self) -> Result<(), CliError> { + let _ = self.shutdown_tx.send(()); + self.task + .await + .map_err(|error| CliError::Launch(format!("gateway task failed: {error}")))? + } +} + +impl PreparedAgentLaunch { + fn from_invocation( + invocation: AgentInvocation, + gateway_url: &str, + resolved: &ResolvedConfig, + dry_run: bool, + ) -> Result { + Self::build( + invocation.agent, + invocation.argv, + invocation.host_index, + gateway_url, + resolved, + dry_run, + ) + } + + #[cfg(test)] + fn new( + agent: CodingAgent, + argv: Vec, + gateway_url: &str, + resolved: &ResolvedConfig, + dry_run: bool, + ) -> Result { + let boundary = argv + .iter() + .position(|argument| argument == "--") + .unwrap_or(argv.len()); + let host_index = argv[..boundary] + .iter() + .rposition(|argument| CodingAgent::infer(argument) == Some(agent)) + .unwrap_or(0); + Self::build(agent, argv, host_index, gateway_url, resolved, dry_run) + } + + // Builds the launch plan and applies only the preparation needed by the selected agent. + // Dry-run preparation records equivalent notes and argv/env changes without writing temporary + // hook files or patching user/project configuration. + fn build( + agent: CodingAgent, + argv: Vec, + host_index: usize, + gateway_url: &str, + resolved: &ResolvedConfig, + dry_run: bool, + ) -> Result { + let mut run = Self { + argv, + host_index, + env: vec![ + ( + crate::configuration::GATEWAY_URL_ENV.into(), + gateway_url.into(), + ), + (crate::configuration::TRANSPARENT_RUN_ENV.into(), "1".into()), + ], + temp_dirs: Vec::new(), + notes: Vec::new(), + }; + if let Some(path) = path_with_transparent_hook_dir() { + run.env.push(("PATH".into(), path)); + } + crate::agents::prepare_launch(agent, &mut run, gateway_url, resolved, dry_run)?; + Ok(run) + } + + // Injects Codex hook and provider configuration through repeated `--config` flags. Codex + // reserves built-in provider IDs, so run mode installs a temporary provider alias instead of + // overriding `model_providers.openai`. Uses `features.hooks=true` introduced in codex-cli + // current supported Codex releases. The centralized host policy validates the version first. + + // Hermes discovers hooks from `.hermes/config.yaml` instead of command-line flags. A + // process-private HERMES_HOME exposes dynamic hooks without rewriting user configuration. + + // Records the Hermes hook file that would be patched during a real run without touching the + // filesystem, preserving dry-run as an inspection-only operation. + + // Spawns the prepared child process with injected environment. + // Stdio is inherited by default so agent interaction remains unchanged in transparent mode. + async fn spawn(&self) -> Result { + let mut command = crate::process::tokio_command(&self.argv); + for (name, value) in &self.env { + command.env(name, value); + } + crate::process::SupervisedChild::spawn(&mut command) + .await + .map_err(CliError::from) + } + + // Removes process-private plugin and configuration directories after the child exits. + fn restore(&self) -> Result<(), CliError> { + for dir in &self.temp_dirs { + match std::fs::remove_dir_all(dir) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(CliError::Io(error)), + } + } + + Ok(()) + } + + // Prints a compact pre-launch status banner so users see at a glance which plugin + // configuration is active, including plugin names and enabled/disabled state, before the + // agent's own UI takes over the terminal. Always emitted on stderr so it never contaminates + // piped/redirected agent output, and suppressed entirely when stdout is not a TTY — scripts + // capturing the agent stream get a clean pipe, interactive users still get the bordered frame. + // Distinct from `print()`, which is the verbose `--print` / `--dry-run` dump intended for + // inspection. + fn print_live_status(&self, agent: CodingAgent, gateway_url: &str, resolved: &ResolvedConfig) { + // Suppress entirely on non-TTY stdout: when the user redirects the agent's stream to a + // file or pipes it into another tool, no banner should appear ahead of that output. + if !std::io::IsTerminal::is_terminal(&std::io::stdout()) { + return; + } + + let mut lines: Vec = Vec::new(); + lines.push(format!("NeMo Relay → {}", agent.as_arg())); + lines.push(format!(" Gateway {gateway_url}")); + let destinations = exporter_destinations(&resolved.gateway); + if destinations.is_empty() { + lines.push(" Exporters not configured".into()); + } else { + for (index, destination) in destinations.iter().enumerate() { + lines.push(format!( + " {}{}", + if index == 0 { + "Exporters " + } else { + " " + }, + destination + )); + } + } + if !self.notes.is_empty() { + lines.push(String::new()); + for note in &self.notes { + lines.push(format!("⚠ {note}")); + } + } + + // Color decisions key off stderr (where we actually emit), not stdout. + let use_color = std::io::IsTerminal::is_terminal(&std::io::stderr()) + && std::env::var_os("NO_COLOR").is_none(); + eprint!("{}", render_status_frame(&lines, use_color)); + } + + // Prints the resolved transparent-run plan, including dynamic gateway URL, upstream base URLs, + // argv/env injection, and any agent-specific notes or temporary files. + fn print(&self, agent: CodingAgent, gateway_url: &str, resolved: &ResolvedConfig) { + println!("agent = {}", agent.as_arg()); + println!("gateway_url = {gateway_url}"); + println!("openai_base_url = {}", resolved.gateway.openai_base_url); + println!( + "anthropic_base_url = {}", + resolved.gateway.anthropic_base_url + ); + println!( + "max_hook_payload_bytes = {}", + resolved.gateway.max_hook_payload_bytes + ); + println!( + "max_passthrough_body_bytes = {}", + resolved.gateway.max_passthrough_body_bytes + ); + let destinations = exporter_destinations(&resolved.gateway); + if destinations.is_empty() { + println!("exporters = not_configured"); + } else { + for destination in destinations { + println!("exporter = {destination}"); + } + } + println!("argv = {}", self.argv.join(" ")); + for (name, value) in &self.env { + println!("env.{name} = {value}"); + } + for note in &self.notes { + println!("note = {note}"); + } + } +} + +// Claude Code honors only the first `--settings` source. Preserve that source in the generated +// overlay so inserting Relay's process-private gateway setting cannot discard user configuration. +// Session hook definitions and their exact trust state share Codex's process-local CLI layer. This +// authorizes only the generated Relay command without rewriting the active user profile or using +// the process-wide hook-trust bypass. + +/// Renders a bordered status frame for daemon and transparent-run startup output. +pub(crate) fn render_status_frame(lines: &[String], color: bool) -> String { + let max_w = lines.iter().map(|l| l.chars().count()).max().unwrap_or(0); + // 1-char padding on each side of the longest line. + let inner = max_w + 2; + let mut output = String::new(); + + output.push('\n'); + push_status_border(&mut output, '╭', '╮', inner, color); + for line in lines { + let pad = max_w - line.chars().count(); + let body = format!(" {line}{spaces} ", spaces = " ".repeat(pad)); + if color { + output.push_str(&format!( + "\x1b[38;5;112m│\x1b[0m{body}\x1b[38;5;112m│\x1b[0m\n" + )); + } else { + output.push_str(&format!("│{body}│\n")); + } + } + push_status_border(&mut output, '╰', '╯', inner, color); + output.push('\n'); + output +} + +pub(crate) fn exporter_destinations(config: &GatewayConfig) -> Vec { + let Some(plugin_config) = config.plugin_config.as_ref() else { + return Vec::new(); + }; + let Ok(plugin_config) = serde_json::from_value::(plugin_config.clone()) else { + return vec!["configured (invalid plugin config)".into()]; + }; + let Some(component) = plugin_config + .components + .iter() + .find(|component| component.kind == OBSERVABILITY_PLUGIN_KIND) + else { + return Vec::new(); + }; + if !component.enabled { + return Vec::new(); + } + let Ok(observability) = + serde_json::from_value::(Value::Object(component.config.clone())) + else { + return vec!["Observability configured (invalid config)".into()]; + }; + observability_exporter_destinations(&observability) +} + +fn observability_exporter_destinations(config: &ObservabilityConfig) -> Vec { + let mut destinations = Vec::new(); + if let Some(section) = config.atof.as_ref().filter(|section| section.enabled) { + for sink in §ion.sinks { + match sink { + AtofSinkSectionConfig::File(file) => { + let directory = file + .output_directory + .clone() + .unwrap_or_else(current_output_directory); + let path = directory.join( + file.filename + .clone() + .unwrap_or_else(|| "nemo-relay-events-.jsonl".into()), + ); + destinations.push(format!("ATOF {}", path.display())); + } + AtofSinkSectionConfig::Stream(stream) => { + destinations.push(format!("ATOF {}", sanitized_url(&stream.url))); + } + } + } + } + if let Some(section) = config.atif.as_ref().filter(|section| section.enabled) { + if section.storage.is_empty() { + let directory = section + .output_directory + .clone() + .unwrap_or_else(current_output_directory); + destinations.push(format!( + "ATIF {}", + directory.join(§ion.filename_template).display() + )); + } else { + // Non-empty `storage` skips the local file write and uploads to each remote backend + // instead, so report the actual upload destinations rather than a local path that is + // never written. + for backend in §ion.storage { + destinations.push(format!("ATIF {}", atif_storage_destination(backend))); + } + } + } + if let Some(section) = config + .opentelemetry + .as_ref() + .filter(|section| section.enabled) + { + destinations.push(format!( + "OpenTelemetry {}", + section + .endpoint + .as_deref() + .map(sanitized_url) + .as_deref() + .unwrap_or("OTLP endpoint from environment/default") + )); + } + if let Some(section) = config + .openinference + .as_ref() + .filter(|section| section.enabled) + { + destinations.push(format!( + "OpenInference {}", + section + .endpoint + .as_deref() + .map(sanitized_url) + .as_deref() + .unwrap_or("OTLP endpoint from environment/default") + )); + } + destinations +} + +// Renders a single ATIF remote storage backend as a human-readable destination for the status +// banner. S3 keys are summarized as `s3:///`; the per-trajectory object suffix +// is omitted because it is only known once a session starts. +fn atif_storage_destination(storage: &AtifStorageConfig) -> String { + match storage { + AtifStorageConfig::Http(http) => sanitized_url(&http.endpoint), + AtifStorageConfig::S3(s3) => { + let prefix = s3.key_prefix.as_deref().unwrap_or("").trim_matches('/'); + if prefix.is_empty() { + format!("s3://{}", s3.bucket) + } else { + format!("s3://{}/{}", s3.bucket, prefix) + } + } + } +} + +fn sanitized_url(value: &str) -> String { + let Ok(mut url) = reqwest::Url::parse(value) else { + return "configured endpoint".into(); + }; + let _ = url.set_username(""); + let _ = url.set_password(None); + if url.query().is_some() { + let keys = url + .query_pairs() + .map(|(key, _)| key.into_owned()) + .collect::>(); + url.set_query(None); + if !keys.is_empty() { + let mut query = url.query_pairs_mut(); + for key in keys { + query.append_pair(&key, "[REDACTED]"); + } + } + } + url.to_string() +} + +fn current_output_directory() -> PathBuf { + std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")) +} + +// Converts a process status into the launcher status code while preserving normal 0-255 exits. Signal +// exits and platform-specific out-of-range codes become generic failure. +fn exit_code(status: std::process::ExitStatus) -> ExitCode { + status + .code() + .and_then(|code| u8::try_from(code).ok()) + .map(ExitCode::from) + .unwrap_or(ExitCode::FAILURE) +} + +// Polls the ephemeral gateway health endpoint for roughly one second before launching the agent. +// Startup failures return a launcher error so the child command is never run against a dead proxy. +async fn wait_for_health(gateway_url: &str, bootstrap_fingerprint: &str) -> Result<(), CliError> { + for _ in 0..50 { + let gateway_url = gateway_url.to_string(); + let bootstrap_fingerprint = bootstrap_fingerprint.to_string(); + if tokio::task::spawn_blocking(move || { + crate::gateway::client::healthz_compatible(&gateway_url, &bootstrap_fingerprint) + }) + .await + .map_err(|error| CliError::Launch(format!("gateway readiness task failed: {error}")))? + { + return Ok(()); + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + Err(CliError::Launch(format!( + "gateway did not become ready at {}/healthz", + gateway_url.trim_end_matches('/') + ))) +} + +// Appends one horizontal border line in NVIDIA green when color is enabled, otherwise plain +// ASCII-compatible box-drawing. +fn push_status_border( + output: &mut String, + left: char, + right: char, + inner_width: usize, + color: bool, +) { + let dashes = "─".repeat(inner_width); + if color { + output.push_str(&format!("\x1b[38;5;112m{left}{dashes}{right}\x1b[0m\n")); + } else { + output.push_str(&format!("{left}{dashes}{right}\n")); + } +} + +// Returns the absolute path of the running gateway binary so injected hooks can find it +// without relying on the user's `PATH`. Spawned hook subprocesses inherit the agent's +// environment; in transparent run, the dev/install location of the gateway is rarely on +// `PATH`, which would cause hooks to exit with status 127 (command not found). Falls back +// to the bare name when `current_exe` is unavailable so behavior degrades to the previous +// install-style assumption rather than failing to launch. + +// Appends the running gateway binary's directory to the child agent PATH. Transparent hooks use +// the absolute executable path when possible, but adding the directory also covers hook loaders or +// user-managed hook commands that resolve `nemo-relay` through PATH inside the launched agent. Keep +// user PATH precedence intact so normal agent tool resolution does not change. +fn path_with_transparent_hook_dir() -> Option { + let dir = std::env::current_exe() + .ok() + .and_then(|path| path.parent().map(Path::to_path_buf))?; + let mut paths: Vec = std::env::var_os("PATH") + .as_deref() + .map(std::env::split_paths) + .into_iter() + .flatten() + .collect(); + if !paths.iter().any(|path| path == &dir) { + paths.push(dir); + } + std::env::join_paths(paths) + .ok() + .map(|path| path.to_string_lossy().into_owned()) +} + +// The invocation resolver determines this index before pass-through arguments are appended. Using +// it here prevents a prompt token named `codex` or `claude` from becoming an accidental insertion +// target while preserving configured wrapper prefixes. + +// Chooses the Hermes config used as the source for a transparent-run overlay. If setup recorded a +// specific path, reuse it; otherwise fall back to the active Hermes home. + +// Converts JSON hook groups into inline TOML arrays for Codex `--config` flags. The function +// preserves matchers when present and assumes generated hook groups contain one command hook. + +// Escapes a Rust string as a TOML basic string for inline Codex configuration values. + +// Creates a uniquely named directory under the OS temp directory. UUIDv7 avoids collisions +// between concurrent transparent runs without keeping persistent coordination state. + +#[cfg(test)] +#[path = "../../tests/coverage/agents/launcher_tests.rs"] +mod tests; diff --git a/crates/cli/src/process/mod.rs b/crates/cli/src/process/mod.rs new file mode 100644 index 000000000..4fb6f7d7c --- /dev/null +++ b/crates/cli/src/process/mod.rs @@ -0,0 +1,219 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Shared coding-agent command parsing, discovery, and process construction. + +pub(crate) mod detached; +pub(crate) mod launcher; +mod prepared; +mod types; + +pub(crate) use prepared::PreparedAgentLaunch; +pub(crate) use prepared::insert_after_host; +pub(crate) use types::RunOverrides; + +use std::ffi::{OsStr, OsString}; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use crate::agents::CodingAgent; + +pub(crate) fn shell_quote_arg_for_platform(raw: &str, windows: bool) -> String { + if windows { + return cmd_quote_arg(raw); + } + posix_quote_arg(raw) +} + +fn posix_quote_arg(raw: &str) -> String { + if raw.is_empty() { + "''".into() + } else if raw + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '/' | ':' | '.' | '_' | '-')) + { + raw.to_string() + } else { + format!("'{}'", raw.replace('\'', "'\\''")) + } +} + +fn cmd_quote_arg(raw: &str) -> String { + if raw.is_empty() { + return "\"\"".into(); + } + if raw.chars().all(|ch| { + ch.is_ascii_alphanumeric() + || matches!(ch, '/' | '\\' | ':' | '.' | '_' | '-' | '=' | '@' | '+') + }) { + return raw.to_string(); + } + let mut escaped = String::new(); + for ch in raw.chars() { + match ch { + '%' => escaped.push_str("%%cd:~,%"), + '"' => escaped.push_str("\"\""), + _ => escaped.push(ch), + } + } + format!("\"{escaped}\"") +} + +#[cfg(windows)] +pub(crate) fn portable_executable_path(path: PathBuf) -> PathBuf { + use std::ffi::OsString; + use std::os::windows::ffi::{OsStrExt, OsStringExt}; + + let encoded = path.as_os_str().encode_wide().collect::>(); + strip_windows_verbatim_prefix(&encoded) + .map(|value| OsString::from_wide(&value)) + .map(PathBuf::from) + .unwrap_or(path) +} + +#[cfg(not(windows))] +pub(crate) fn portable_executable_path(path: PathBuf) -> PathBuf { + path +} + +#[cfg(any(test, windows))] +pub(crate) fn strip_windows_verbatim_prefix(encoded: &[u16]) -> Option> { + const PREFIX: &[u16] = &[b'\\' as u16, b'\\' as u16, b'?' as u16, b'\\' as u16]; + const UNC: &[u16] = &[ + b'\\' as u16, + b'\\' as u16, + b'?' as u16, + b'\\' as u16, + b'U' as u16, + b'N' as u16, + b'C' as u16, + b'\\' as u16, + ]; + if let Some(rest) = encoded.strip_prefix(UNC) { + let mut normalized = vec![b'\\' as u16, b'\\' as u16]; + normalized.extend_from_slice(rest); + Some(normalized) + } else { + encoded.strip_prefix(PREFIX).map(ToOwned::to_owned) + } +} + +/// Parses the intentionally simple command strings accepted by `[agents.*].command`. +/// +/// Complex shell expressions belong after `nemo-relay run --`; configuration values are argv +/// prefixes and therefore use whitespace separation consistently in launch and diagnostics. +pub(crate) fn command_argv(command: &str) -> Vec { + command.split_whitespace().map(ToOwned::to_owned).collect() +} + +/// Builds the host version probe while preserving a configured wrapper prefix. +/// +/// The last recognizable host token wins so package selectors such as +/// `npm exec --package @openai/codex -- codex` do not truncate the probe at the package name. +/// Opaque wrappers must expose the selected host's version when passed `--version`. +pub(crate) fn version_probe_argv(agent: CodingAgent, argv: &[String]) -> Vec { + let mut probe = argv + .iter() + .rposition(|argument| CodingAgent::infer(argument) == Some(agent)) + .map_or_else(|| argv.to_vec(), |index| argv[..=index].to_vec()); + if probe.is_empty() { + probe.push(agent.executable().into()); + } + probe.push("--version".into()); + probe +} + +/// Resolves a command using the current platform's executable conventions. +pub(crate) fn resolve_executable(command: &str) -> Option { + resolve_executable_for_platform( + command, + std::env::var_os("PATH").as_deref(), + std::env::var_os("PATHEXT").as_deref(), + cfg!(windows), + ) +} + +/// Resolves a command against an explicit PATH. This keeps setup detection deterministic in tests. +pub(crate) fn resolve_executable_in_path(command: &str, path: Option<&OsStr>) -> Option { + resolve_executable_for_platform( + command, + path, + std::env::var_os("PATHEXT").as_deref(), + cfg!(windows), + ) +} + +pub(crate) fn resolve_executable_for_platform( + command: &str, + path: Option<&OsStr>, + path_ext: Option<&OsStr>, + windows: bool, +) -> Option { + if command.is_empty() { + return None; + } + let command_path = Path::new(command); + let extensions = executable_extensions(command_path, path_ext, windows); + if command_path.is_absolute() || command_path.components().count() > 1 { + return resolve_candidate(command_path, &extensions); + } + path.into_iter() + .flat_map(std::env::split_paths) + .find_map(|directory| resolve_candidate(&directory.join(command), &extensions)) +} + +fn executable_extensions(command: &Path, path_ext: Option<&OsStr>, windows: bool) -> Vec { + if !windows || command.extension().is_some() { + return vec![OsString::new()]; + } + path_ext + .and_then(OsStr::to_str) + .filter(|value| !value.is_empty()) + .unwrap_or(".EXE;.CMD;.BAT;.COM") + .split(';') + .filter(|extension| !extension.is_empty()) + .map(OsString::from) + .collect() +} + +fn resolve_candidate(base: &Path, extensions: &[OsString]) -> Option { + extensions.iter().find_map(|extension| { + let candidate = if extension.is_empty() { + base.to_path_buf() + } else { + let mut value = base.as_os_str().to_os_string(); + value.push(extension); + PathBuf::from(value) + }; + candidate.is_file().then_some(candidate) + }) +} + +/// Creates a synchronous command. +/// +/// Rust's Windows process implementation recognizes `.cmd` and `.bat` programs and applies its +/// hardened batch-file argument encoder. Keeping process construction here argv-based avoids +/// reinterpreting host arguments through a second, hand-built shell command line. +pub(crate) fn std_command(argv: &[String]) -> Command { + debug_assert!(!argv.is_empty()); + let program = resolve_executable(&argv[0]).unwrap_or_else(|| PathBuf::from(&argv[0])); + let mut command = Command::new(program); + command.args(&argv[1..]); + command +} + +/// Creates an asynchronous command with the same argv behavior as [`std_command`]. +pub(crate) fn tokio_command(argv: &[String]) -> tokio::process::Command { + debug_assert!(!argv.is_empty()); + let program = resolve_executable(&argv[0]).unwrap_or_else(|| PathBuf::from(&argv[0])); + let mut command = tokio::process::Command::new(program); + command.args(&argv[1..]); + command +} + +mod supervision; +pub(crate) use supervision::SupervisedChild; + +#[cfg(test)] +#[path = "../../tests/coverage/shared/agent_process_tests.rs"] +mod tests; diff --git a/crates/cli/src/process/prepared.rs b/crates/cli/src/process/prepared.rs new file mode 100644 index 000000000..b365ae667 --- /dev/null +++ b/crates/cli/src/process/prepared.rs @@ -0,0 +1,22 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use std::path::PathBuf; + +/// Fully resolved child-process launch plan produced by one agent integration. +pub(crate) struct PreparedAgentLaunch { + pub(crate) argv: Vec, + pub(crate) host_index: usize, + pub(crate) env: Vec<(String, String)>, + pub(crate) temp_dirs: Vec, + pub(crate) notes: Vec, +} + +pub(crate) fn insert_after_host( + argv: &mut Vec, + host_index: usize, + values: impl IntoIterator, +) { + debug_assert!(host_index < argv.len()); + argv.splice(host_index + 1..host_index + 1, values); +} diff --git a/crates/cli/src/process/supervision.rs b/crates/cli/src/process/supervision.rs new file mode 100644 index 000000000..fedd4cd53 --- /dev/null +++ b/crates/cli/src/process/supervision.rs @@ -0,0 +1,123 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Ownership and cleanup for coding-agent wrapper process trees. + +use std::process::ExitStatus; + +#[cfg(not(any(unix, windows)))] +mod fallback; +#[cfg(not(any(unix, windows)))] +use fallback as platform; +#[cfg(unix)] +mod unix; +#[cfg(unix)] +use unix as platform; +#[cfg(windows)] +mod windows; +#[cfg(windows)] +use windows as platform; + +/// A coding-agent process whose complete wrapper tree is owned by Relay. +/// +/// Transparent runs accept wrapper commands such as `npx codex`. Killing only the immediate +/// wrapper can leave the real agent running after its private gateway and hook configuration have +/// been removed, so the platform implementation owns and cleans up the entire tree. +pub(crate) struct SupervisedChild { + child: tokio::process::Child, + tree: platform::ProcessTree, + tree_active: bool, +} + +impl SupervisedChild { + /// Spawns a command into an independently terminable process tree. + pub(crate) async fn spawn(command: &mut tokio::process::Command) -> std::io::Result { + command.kill_on_drop(true); + let (child, tree) = platform::spawn(command).await?; + Ok(Self { + child, + tree, + tree_active: true, + }) + } + + /// Waits for the wrapper and terminates any descendants it left behind. + pub(crate) async fn wait(&mut self) -> std::io::Result { + let status = match platform::wait(&mut self.tree, &mut self.child).await { + Ok(status) => status, + Err(wait_error) => return Err(self.clean_up_wait_error(wait_error).await), + }; + let terminal_result = self.tree.restore_terminal(); + let tree_result = self.tree.terminate(&mut self.child); + if tree_result.is_ok() { + self.tree_active = false; + } + combine_cleanup_results([ + ("restore foreground terminal", terminal_result), + ("terminate remaining coding-agent descendants", tree_result), + ])?; + Ok(status) + } + + #[cfg(all(test, unix))] + pub(super) async fn inject_wait_error_for_test( + &mut self, + error: std::io::Error, + ) -> std::io::Result { + Err(self.clean_up_wait_error(error).await) + } + + async fn clean_up_wait_error(&mut self, error: std::io::Error) -> std::io::Error { + let cleanup_error = self.terminate().await.err(); + let detail = cleanup_error.map_or_else(String::new, |cleanup_error| { + format!("; additionally failed to terminate the coding-agent tree: {cleanup_error}") + }); + std::io::Error::new( + error.kind(), + format!("failed while supervising the coding-agent tree: {error}{detail}"), + ) + } + + /// Terminates and reaps the complete supervised process tree. + pub(crate) async fn terminate(&mut self) -> std::io::Result<()> { + let terminal_result = self.tree.restore_terminal(); + let tree_result = self.tree.terminate(&mut self.child); + if tree_result.is_err() { + // Preserve direct-child cleanup even if the platform tree primitive failed. The + // original tree error remains authoritative because descendants may still be alive. + let _ = self.child.start_kill(); + } + let wait_result = self.child.wait().await.map(|_| ()); + if tree_result.is_ok() { + self.tree_active = false; + } + combine_cleanup_results([ + ("restore foreground terminal", terminal_result), + ("terminate coding-agent process tree", tree_result), + ("reap coding-agent wrapper", wait_result), + ]) + } +} + +impl Drop for SupervisedChild { + fn drop(&mut self) { + let _ = self.tree.restore_terminal(); + if self.tree_active { + let _ = self.tree.terminate(&mut self.child); + } + } +} + +pub(super) fn combine_cleanup_results( + results: [(&str, std::io::Result<()>); N], +) -> std::io::Result<()> { + let errors = results + .into_iter() + .filter_map(|(operation, result)| result.err().map(|error| format!("{operation}: {error}"))) + .collect::>(); + if errors.is_empty() { + Ok(()) + } else { + Err(std::io::Error::other(errors.join("; "))) + } +} diff --git a/crates/cli/src/process/supervision/fallback.rs b/crates/cli/src/process/supervision/fallback.rs new file mode 100644 index 000000000..a0c37709c --- /dev/null +++ b/crates/cli/src/process/supervision/fallback.rs @@ -0,0 +1,31 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Direct-child fallback for platforms without process-group or Job Object support. + +use std::process::ExitStatus; + +pub(super) struct ProcessTree; + +pub(super) async fn spawn( + command: &mut tokio::process::Command, +) -> std::io::Result<(tokio::process::Child, ProcessTree)> { + command.spawn().map(|child| (child, ProcessTree)) +} + +pub(super) async fn wait( + _tree: &mut ProcessTree, + child: &mut tokio::process::Child, +) -> std::io::Result { + child.wait().await +} + +impl ProcessTree { + pub(super) fn restore_terminal(&mut self) -> std::io::Result<()> { + Ok(()) + } + + pub(super) fn terminate(&mut self, child: &mut tokio::process::Child) -> std::io::Result<()> { + child.start_kill() + } +} diff --git a/crates/cli/src/process/supervision/unix.rs b/crates/cli/src/process/supervision/unix.rs new file mode 100644 index 000000000..e4101da18 --- /dev/null +++ b/crates/cli/src/process/supervision/unix.rs @@ -0,0 +1,365 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Unix process-group ownership with foreground terminal job control. + +use std::process::ExitStatus; +use std::time::Duration; + +use super::combine_cleanup_results; + +const TERMINATION_GRACE_PERIOD: Duration = Duration::from_secs(5); + +pub(super) struct ProcessTree { + process_group: i32, + terminal: Option, + signals: TerminationSignals, +} + +pub(super) async fn spawn( + command: &mut tokio::process::Command, +) -> std::io::Result<(tokio::process::Child, ProcessTree)> { + // Register before spawning so a signal cannot terminate Relay in the interval between child + // creation and supervision. Tokio retains the OS handlers process-wide, which is appropriate: + // a transparent run exits immediately after this child finishes. + let signals = TerminationSignals::new()?; + let terminal_owner = terminal_foreground_owner()?; + command.process_group(0); + let mut child = command.spawn()?; + let process_group = child.id().ok_or_else(|| { + std::io::Error::other("coding-agent process exited before Relay could supervise it") + })? as i32; + let terminal = match terminal_owner { + Some(owner) => match TerminalForeground::acquire(owner, process_group) { + Ok(terminal) => Some(terminal), + Err(error) => { + terminate_group(process_group, &mut child); + let _ = child.wait().await; + return Err(std::io::Error::new( + error.kind(), + format!( + "failed to give the coding agent foreground terminal ownership: {error}" + ), + )); + } + }, + None => None, + }; + Ok(( + child, + ProcessTree { + process_group, + terminal, + signals, + }, + )) +} + +pub(super) async fn wait( + tree: &mut ProcessTree, + child: &mut tokio::process::Child, +) -> std::io::Result { + let mut termination_deadline = None; + loop { + if let Some(status) = child.try_wait()? { + return Ok(status); + } + if tree.resume_foreground_child()? { + // SIGCONT changes the child group out of a stopped state in the kernel. Yield once so + // a following WNOWAIT probe cannot observe the stop transition being cleared. + tokio::task::yield_now().await; + continue; + } + if tree.terminal.is_some() && child_is_stopped(tree.process_group)? { + tree.restore_terminal()?; + tree.stop_supervisor_group()?; + if let Some(status) = child.try_wait()? { + return Ok(status); + } + if let Some(terminal) = tree.terminal.as_mut() { + terminal.resume_after_supervisor()?; + } + } + if termination_deadline.is_some_and(|deadline| tokio::time::Instant::now() >= deadline) { + tree.terminate(child)?; + return child.wait().await; + } + tokio::select! { + _ = tokio::time::sleep(Duration::from_millis(25)) => {} + signal = tree.signals.recv() => { + let signal = signal?; + if termination_deadline.is_some() { + tree.terminate(child)?; + return child.wait().await; + } + tree.forward_signal(signal, child)?; + termination_deadline = Some(tokio::time::Instant::now() + TERMINATION_GRACE_PERIOD); + } + } + } +} + +impl ProcessTree { + pub(super) fn restore_terminal(&mut self) -> std::io::Result<()> { + self.terminal + .as_mut() + .map_or(Ok(()), TerminalForeground::restore) + } + + pub(super) fn terminate(&mut self, child: &mut tokio::process::Child) -> std::io::Result<()> { + // SAFETY: The child was spawned with `process_group(0)`, so its PID is the process-group + // ID. A negative PID targets the complete group and does not dereference memory. + if unsafe { libc::kill(-self.process_group, libc::SIGKILL) } == 0 { + return Ok(()); + } + let error = std::io::Error::last_os_error(); + if error.raw_os_error() == Some(libc::ESRCH) { + // The group can disappear between the wrapper exit and cleanup. If the wrapper moved + // itself out of the group, retain the direct-child guarantee as a safe fallback. + let _ = child.start_kill(); + Ok(()) + } else { + Err(error) + } + } + + fn forward_signal( + &mut self, + signal: i32, + child: &mut tokio::process::Child, + ) -> std::io::Result<()> { + // SAFETY: The child was placed in this independently owned process group before it ran. + if unsafe { libc::kill(-self.process_group, signal) } == 0 { + return Ok(()); + } + let error = std::io::Error::last_os_error(); + if error.raw_os_error() == Some(libc::ESRCH) { + // If a wrapper moved itself out of the group, retain the stronger no-orphan guarantee. + child.start_kill() + } else { + Err(error) + } + } + + fn stop_supervisor_group(&self) -> std::io::Result<()> { + let Some(terminal) = &self.terminal else { + return Ok(()); + }; + // The shell owns and resumes foreground jobs by process group. Stopping only the Relay PID + // would leave a non-exec wrapper or pipeline sibling running and prevent correct job-state + // reporting by the shell. + // SAFETY: `owner_process_group` was read from getpgrp for this live foreground job. + if unsafe { libc::kill(-terminal.owner_process_group, libc::SIGSTOP) } == -1 { + Err(std::io::Error::last_os_error()) + } else { + Ok(()) + } + } + + fn resume_foreground_child(&mut self) -> std::io::Result { + let Some(terminal) = self.terminal.as_mut() else { + return Ok(false); + }; + if terminal.active || terminal_process_group()? != terminal.owner_process_group { + return Ok(false); + } + // A background agent does not have to read the terminal. If a shell later runs `fg` while + // that agent is still running, Relay receives no new SIGCONT to wake a dedicated handler; + // observe the terminal handoff here and complete it for the child group. + terminal.activate()?; + Ok(true) + } +} + +struct TerminalForeground { + owner_process_group: i32, + child_process_group: i32, + active: bool, +} + +impl TerminalForeground { + fn acquire(owner_process_group: i32, child_process_group: i32) -> std::io::Result { + let mut terminal = Self { + owner_process_group, + child_process_group, + active: false, + }; + terminal.activate()?; + Ok(terminal) + } + + fn activate(&mut self) -> std::io::Result<()> { + set_terminal_process_group(self.child_process_group)?; + self.active = true; + // The child can race to read before the parent transfers the terminal and stop with + // SIGTTIN. Continuing the whole group after the transfer closes that standard job-control + // race and also resumes a user-stopped agent after Relay itself is continued. + if let Err(error) = self.continue_child() { + return combine_cleanup_results([ + ("continue foreground coding-agent group", Err(error)), + ("restore foreground terminal", self.restore()), + ]); + } + Ok(()) + } + + fn resume_after_supervisor(&mut self) -> std::io::Result<()> { + // A shell's `fg` first returns Relay's group to the foreground and then continues it. `bg` + // only continues Relay while the shell stays foreground. Preserve that distinction: a + // background agent may run, or stop naturally with SIGTTIN if it attempts terminal input. + if terminal_process_group()? == self.owner_process_group { + self.activate() + } else { + self.continue_child() + } + } + + fn continue_child(&self) -> std::io::Result<()> { + // SAFETY: A negative PID targets the process group created for this child. + if unsafe { libc::kill(-self.child_process_group, libc::SIGCONT) } == -1 { + let error = std::io::Error::last_os_error(); + if error.raw_os_error() != Some(libc::ESRCH) { + return Err(error); + } + } + Ok(()) + } + + fn restore(&mut self) -> std::io::Result<()> { + if !self.active { + return Ok(()); + } + set_terminal_process_group(self.owner_process_group)?; + self.active = false; + Ok(()) + } +} + +struct TerminationSignals { + hangup: tokio::signal::unix::Signal, + interrupt: tokio::signal::unix::Signal, + quit: tokio::signal::unix::Signal, + terminate: tokio::signal::unix::Signal, +} + +impl TerminationSignals { + fn new() -> std::io::Result { + use tokio::signal::unix::{SignalKind, signal}; + + Ok(Self { + hangup: signal(SignalKind::hangup())?, + interrupt: signal(SignalKind::interrupt())?, + quit: signal(SignalKind::quit())?, + terminate: signal(SignalKind::terminate())?, + }) + } + + async fn recv(&mut self) -> std::io::Result { + let signal = tokio::select! { + signal = self.hangup.recv() => signal.map(|()| libc::SIGHUP), + signal = self.interrupt.recv() => signal.map(|()| libc::SIGINT), + signal = self.quit.recv() => signal.map(|()| libc::SIGQUIT), + signal = self.terminate.recv() => signal.map(|()| libc::SIGTERM), + }; + signal.ok_or_else(|| std::io::Error::other("transparent-run signal receiver closed")) + } +} + +fn terminal_foreground_owner() -> std::io::Result> { + // SAFETY: STDIN_FILENO is a process-owned descriptor. `isatty` does not modify it. + if unsafe { libc::isatty(libc::STDIN_FILENO) } == 0 { + return Ok(None); + } + // SAFETY: `getpgrp` has no preconditions and cannot fail. + let owner_process_group = unsafe { libc::getpgrp() }; + let foreground_process_group = terminal_process_group()?; + if foreground_process_group != owner_process_group { + return Err(std::io::Error::new( + std::io::ErrorKind::WouldBlock, + "Relay is not the terminal foreground process; bring the transparent run to the foreground or redirect its standard input", + )); + } + Ok(Some(owner_process_group)) +} + +fn terminal_process_group() -> std::io::Result { + // SAFETY: STDIN_FILENO was verified as a terminal during process-tree preparation. + let foreground_process_group = unsafe { libc::tcgetpgrp(libc::STDIN_FILENO) }; + if foreground_process_group == -1 { + Err(std::io::Error::last_os_error()) + } else { + Ok(foreground_process_group) + } +} + +fn set_terminal_process_group(process_group: i32) -> std::io::Result<()> { + let mut blocked = std::mem::MaybeUninit::::uninit(); + let mut previous = std::mem::MaybeUninit::::uninit(); + // SAFETY: Both pointers reference valid sigset_t storage. Blocking SIGTTOU on this thread lets + // the background supervisor reclaim the foreground terminal without stopping itself. + let mask_result = unsafe { + libc::sigemptyset(blocked.as_mut_ptr()); + libc::sigaddset(blocked.as_mut_ptr(), libc::SIGTTOU); + libc::pthread_sigmask(libc::SIG_BLOCK, blocked.as_ptr(), previous.as_mut_ptr()) + }; + if mask_result != 0 { + return Err(std::io::Error::from_raw_os_error(mask_result)); + } + // SAFETY: The descriptor is a controlling terminal checked during acquisition, and the target + // is either the original foreground group or the supervised child group in the same session. + let foreground_result = unsafe { libc::tcsetpgrp(libc::STDIN_FILENO, process_group) }; + let foreground_error = (foreground_result == -1).then(std::io::Error::last_os_error); + // SAFETY: `previous` was initialized by the successful pthread_sigmask call above. + let restore_result = unsafe { + libc::pthread_sigmask( + libc::SIG_SETMASK, + previous.assume_init_ref(), + std::ptr::null_mut(), + ) + }; + combine_cleanup_results([ + ( + "set terminal foreground process group", + foreground_error.map_or(Ok(()), Err), + ), + ( + "restore supervisor signal mask", + if restore_result == 0 { + Ok(()) + } else { + Err(std::io::Error::from_raw_os_error(restore_result)) + }, + ), + ]) +} + +fn child_is_stopped(pid: i32) -> std::io::Result { + let mut info = std::mem::MaybeUninit::::zeroed(); + // SAFETY: `info` points to writable siginfo_t storage. WNOWAIT observes only stop state and + // leaves the eventual exit status for Tokio to reap. + let result = unsafe { + libc::waitid( + libc::P_PID, + pid as _, + info.as_mut_ptr(), + libc::WSTOPPED | libc::WNOHANG | libc::WNOWAIT, + ) + }; + if result == -1 { + let error = std::io::Error::last_os_error(); + return if error.raw_os_error() == Some(libc::ECHILD) { + Ok(false) + } else { + Err(error) + }; + } + // SAFETY: waitid initialized `info` on success; a zero si_pid means no state was available. + Ok(unsafe { info.assume_init().si_pid() } == pid) +} + +fn terminate_group(process_group: i32, child: &mut tokio::process::Child) { + // SAFETY: The negative PID targets the child process group and does not dereference memory. + if unsafe { libc::kill(-process_group, libc::SIGKILL) } == -1 { + let _ = child.start_kill(); + } +} diff --git a/crates/cli/src/process/supervision/windows.rs b/crates/cli/src/process/supervision/windows.rs new file mode 100644 index 000000000..759d7a312 --- /dev/null +++ b/crates/cli/src/process/supervision/windows.rs @@ -0,0 +1,215 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Race-free Windows Job Object ownership for coding-agent wrapper trees. + +use std::process::ExitStatus; + +pub(super) struct ProcessTree { + job: AgentJob, +} + +pub(super) async fn spawn( + command: &mut tokio::process::Command, +) -> std::io::Result<(tokio::process::Child, ProcessTree)> { + let job = AgentJob::create()?; + command.creation_flags(windows_sys::Win32::System::Threading::CREATE_SUSPENDED); + let mut child = command.spawn()?; + if let Err(error) = job.assign(&child) { + abort_spawn(&job, &mut child).await; + return Err(error); + } + if let Err(error) = resume_suspended_process(child.id().ok_or_else(|| { + std::io::Error::other("coding-agent process exited before Relay could resume it") + })?) { + abort_spawn(&job, &mut child).await; + return Err(error); + } + Ok((child, ProcessTree { job })) +} + +pub(super) async fn wait( + _tree: &mut ProcessTree, + child: &mut tokio::process::Child, +) -> std::io::Result { + child.wait().await +} + +impl ProcessTree { + pub(super) fn restore_terminal(&mut self) -> std::io::Result<()> { + Ok(()) + } + + pub(super) fn terminate(&mut self, _child: &mut tokio::process::Child) -> std::io::Result<()> { + self.job.terminate() + } +} + +struct AgentJob { + handle: windows_sys::Win32::Foundation::HANDLE, +} + +const WINDOWS_JOB_OBJECT_LIMIT_KILL_ON_CLOSE: u32 = 0x0000_2000; + +// SAFETY: Job Object handles can be used from any thread, and this wrapper uniquely owns it. +unsafe impl Send for AgentJob {} +// SAFETY: Windows Job Object operations are thread-safe for a live kernel handle. +unsafe impl Sync for AgentJob {} + +impl AgentJob { + fn create() -> std::io::Result { + use windows_sys::Win32::Foundation::CloseHandle; + use windows_sys::Win32::System::JobObjects::{ + CreateJobObjectW, JOBOBJECT_EXTENDED_LIMIT_INFORMATION, + JobObjectExtendedLimitInformation, SetInformationJobObject, + }; + + // SAFETY: Null security attributes and name request a private, unnamed Job Object. + let handle = unsafe { CreateJobObjectW(std::ptr::null(), std::ptr::null()) }; + if handle.is_null() { + return Err(last_windows_error( + "failed to create coding-agent Job Object", + )); + } + let mut limits = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default(); + limits.BasicLimitInformation.LimitFlags = WINDOWS_JOB_OBJECT_LIMIT_KILL_ON_CLOSE; + // SAFETY: `handle` is live and `limits` is correctly sized for the requested class. + let configured = unsafe { + SetInformationJobObject( + handle, + JobObjectExtendedLimitInformation, + std::ptr::from_ref(&limits).cast(), + std::mem::size_of::() as u32, + ) + }; + if configured == 0 { + let error = last_windows_error("failed to configure coding-agent Job Object cleanup"); + // SAFETY: `handle` was created above and has not been transferred. + unsafe { CloseHandle(handle) }; + return Err(error); + } + Ok(Self { handle }) + } + + fn assign(&self, child: &tokio::process::Child) -> std::io::Result<()> { + use windows_sys::Win32::System::JobObjects::AssignProcessToJobObject; + + let child_handle = child.raw_handle().ok_or_else(|| { + std::io::Error::other("coding-agent process exited before Job Object assignment") + })?; + // SAFETY: Both handles are live kernel handles owned by this process. + if unsafe { AssignProcessToJobObject(self.handle, child_handle.cast()) } == 0 { + Err(last_windows_error(&format!( + "failed to assign coding-agent process {} to its Job Object; the current Windows Job Object may reject nested assignment", + child.id().unwrap_or_default() + ))) + } else { + Ok(()) + } + } + + fn terminate(&self) -> std::io::Result<()> { + use windows_sys::Win32::System::JobObjects::TerminateJobObject; + + // SAFETY: This handle owns the Job Object assigned to the coding-agent process tree. + if unsafe { TerminateJobObject(self.handle, 1) } == 0 { + Err(last_windows_error( + "failed to terminate coding-agent Job Object", + )) + } else { + Ok(()) + } + } +} + +async fn abort_spawn(job: &AgentJob, child: &mut tokio::process::Child) { + // The process is still suspended when assignment or resume fails, so no descendant can escape + // before the Job Object and direct-child fallbacks terminate it. + let _ = job.terminate(); + let _ = child.start_kill(); + let _ = child.wait().await; +} + +fn resume_suspended_process(process_id: u32) -> std::io::Result<()> { + use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE}; + use windows_sys::Win32::System::Diagnostics::ToolHelp::{ + CreateToolhelp32Snapshot, TH32CS_SNAPTHREAD, THREADENTRY32, Thread32First, Thread32Next, + }; + use windows_sys::Win32::System::Threading::{OpenThread, ResumeThread, THREAD_SUSPEND_RESUME}; + + // SAFETY: A system-wide thread snapshot does not borrow caller memory. + let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0) }; + if snapshot == INVALID_HANDLE_VALUE { + return Err(last_windows_error( + "failed to enumerate the suspended coding-agent thread", + )); + } + let mut entry = THREADENTRY32 { + dwSize: std::mem::size_of::() as u32, + ..THREADENTRY32::default() + }; + // SAFETY: `snapshot` is live and `entry` is correctly sized writable storage. + let mut has_entry = unsafe { Thread32First(snapshot, &mut entry) } != 0; + while has_entry { + if entry.th32OwnerProcessID == process_id { + // SAFETY: The snapshot supplied this live thread identifier and only resume access is + // requested. + let thread = unsafe { OpenThread(THREAD_SUSPEND_RESUME, 0, entry.th32ThreadID) }; + if thread.is_null() { + let error = + last_windows_error("failed to open the suspended coding-agent primary thread"); + // SAFETY: `snapshot` is uniquely owned and closed exactly once on this path. + unsafe { CloseHandle(snapshot) }; + return Err(error); + } + // CREATE_SUSPENDED starts the primary thread with a suspend count of one. Resume until + // that count reaches zero, while rejecting a zero count that would imply the process + // had already run before Job Object assignment. + // SAFETY: `thread` is live and was opened with THREAD_SUSPEND_RESUME. + let mut previous_count = unsafe { ResumeThread(thread) }; + while previous_count > 1 && previous_count != u32::MAX { + // SAFETY: The same live thread handle remains owned by this function. + previous_count = unsafe { ResumeThread(thread) }; + } + let resume_error = if previous_count == u32::MAX { + Some(last_windows_error( + "failed to resume the Job-owned coding-agent process", + )) + } else if previous_count == 0 { + Some(std::io::Error::other( + "coding-agent primary thread was not suspended before Job Object assignment", + )) + } else { + None + }; + // SAFETY: Both handles are uniquely owned and closed exactly once on this path. + unsafe { + CloseHandle(thread); + CloseHandle(snapshot); + } + return resume_error.map_or(Ok(()), Err); + } + // SAFETY: `snapshot` and `entry` remain valid for the next enumeration result. + has_entry = unsafe { Thread32Next(snapshot, &mut entry) } != 0; + } + // SAFETY: `snapshot` is uniquely owned and closed exactly once on this path. + unsafe { CloseHandle(snapshot) }; + Err(std::io::Error::other(format!( + "could not find the suspended primary thread for coding-agent process {process_id}" + ))) +} + +fn last_windows_error(context: &str) -> std::io::Error { + let source = std::io::Error::last_os_error(); + std::io::Error::new(source.kind(), format!("{context}: {source}")) +} + +impl Drop for AgentJob { + fn drop(&mut self) { + use windows_sys::Win32::Foundation::CloseHandle; + + // SAFETY: `handle` is uniquely owned by this wrapper and closed exactly once. The Job + // Object's kill-on-close limit provides a final descendant-cleanup guarantee. + unsafe { CloseHandle(self.handle) }; + } +} diff --git a/crates/cli/src/process/types.rs b/crates/cli/src/process/types.rs new file mode 100644 index 000000000..d86aea678 --- /dev/null +++ b/crates/cli/src/process/types.rs @@ -0,0 +1,19 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use std::path::PathBuf; + +use crate::agents::CodingAgent; + +#[derive(Debug, Clone)] +pub(crate) struct RunOverrides { + pub(crate) agent: Option, + pub(crate) config: Option, + pub(crate) openai_base_url: Option, + pub(crate) anthropic_base_url: Option, + pub(crate) session_metadata: Option, + pub(crate) plugin_config_path: Option, + pub(crate) dry_run: bool, + pub(crate) print: bool, + pub(crate) command: Vec, +} diff --git a/crates/cli/src/server.rs b/crates/cli/src/server.rs deleted file mode 100644 index e76a8b3b7..000000000 --- a/crates/cli/src/server.rs +++ /dev/null @@ -1,454 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -use std::future::Future; -use std::net::SocketAddr; -use std::pin::Pin; -use std::sync::{Arc, Mutex}; -use std::time::{Duration, Instant}; - -use axum::extract::rejection::JsonRejection; -use axum::extract::{DefaultBodyLimit, State}; -use axum::http::HeaderMap; -use axum::routing::{get, post}; -use axum::{Json, Router}; -use nemo_relay::plugin::dynamic::{DynamicPluginActivationSpec, PluginHostActivation}; -use nemo_relay::plugin::{PluginConfig, clear_plugin_configuration, initialize_plugins_exact}; -use nemo_relay_adaptive::plugin_component::register_adaptive_component; -use nemo_relay_pii_redaction::component::register_pii_redaction_component; -#[cfg(feature = "switchyard")] -use nemo_relay_switchyard::{ - register_switchyard_component, validate_switchyard_atof_configuration, -}; -use reqwest::Client; -use serde_json::Value; -use tokio::net::TcpListener; -use tokio::sync::oneshot; - -use crate::adapters::{claude_code, codex, hermes}; -use crate::config::GatewayConfig; -use crate::error::CliError; -use crate::gateway; -use crate::plugins::lifecycle::ActiveDynamicPluginComponent; -use crate::session::SessionManager; - -const HTTP_CONNECT_TIMEOUT: Duration = Duration::from_secs(30); -const HTTP_REQUEST_TIMEOUT: Duration = Duration::from_secs(300); -const HTTP_READ_TIMEOUT: Duration = Duration::from_secs(300); - -#[derive(Clone)] -pub(crate) struct AppState { - pub(crate) config: GatewayConfig, - pub(crate) http: Client, - pub(crate) sessions: SessionManager, - pub(crate) last_activity: Arc>, -} - -/// Binds the configured address and activates enabled dynamic plugins before serving. -pub(crate) async fn serve_with_dynamic( - config: GatewayConfig, - dynamic_plugins: Vec, -) -> Result<(), CliError> { - let listener = TcpListener::bind(config.bind).await.map_err(|err| { - // Translate the common bind-failure (port already in use) into an actionable message. - // Plain `io error: Address already in use (os error 48)` is unhelpful; the friendly - // version names the likely cause and points at the real fixes. - if err.kind() == std::io::ErrorKind::AddrInUse { - CliError::Launch(format!( - "cannot bind {} — port is already in use. Most likely cause: another \ - `nemo-relay` daemon is already running. Fix one of:\n \ - • stop the running daemon (Unix: `pkill -f nemo-relay`, Windows: \ - `taskkill /IM nemo-relay.exe`)\n \ - • use an ephemeral port: `nemo-relay --bind 127.0.0.1:0`\n \ - • pick a free port: `nemo-relay --bind 127.0.0.1:4041`", - config.bind - )) - } else { - CliError::Io(err) - } - })?; - print_startup_status(listener.local_addr()?, &config); - serve_listener_with_dynamic_inner( - listener, - config, - dynamic_plugins, - Some(ShutdownMode::ProcessSignal), - ) - .await -} - -fn print_startup_status(bind: SocketAddr, config: &GatewayConfig) { - let use_color = std::io::IsTerminal::is_terminal(&std::io::stderr()) - && std::env::var_os("NO_COLOR").is_none(); - eprint!("{}", render_startup_status(bind, config, use_color)); -} - -fn render_startup_status(bind: SocketAddr, config: &GatewayConfig, color: bool) -> String { - let mut lines = vec![ - "NeMo Relay".to_string(), - format!(" Gateway http://{bind}"), - ]; - let destinations = crate::launcher::exporter_destinations(config); - if destinations.is_empty() { - lines.push(" Exporters not configured".into()); - } else { - for (index, destination) in destinations.iter().enumerate() { - lines.push(format!( - " {}{}", - if index == 0 { - "Exporters " - } else { - " " - }, - destination - )); - } - } - - crate::launcher::render_status_frame(&lines, color) -} - -/// Serves the gateway router on a caller-owned listener with optional graceful shutdown. -/// -/// A provided shutdown receiver is best-effort: the send side may be dropped after the child agent -/// exits, and either receiving or channel closure is enough to let Axum drain the listener. -#[cfg(test)] -pub(crate) async fn serve_listener( - listener: TcpListener, - config: GatewayConfig, - shutdown: Option>, -) -> Result<(), CliError> { - serve_listener_with_dynamic(listener, config, Vec::new(), shutdown).await -} - -/// Serves the gateway router and activates enabled dynamic plugin components. -pub(crate) async fn serve_listener_with_dynamic( - listener: TcpListener, - config: GatewayConfig, - dynamic_plugins: Vec, - shutdown: Option>, -) -> Result<(), CliError> { - serve_listener_with_dynamic_inner( - listener, - config, - dynamic_plugins, - shutdown.map(ShutdownMode::Receiver), - ) - .await -} - -type ShutdownFuture = Pin + Send>>; - -enum ShutdownMode { - Receiver(oneshot::Receiver<()>), - ProcessSignal, -} - -async fn serve_listener_with_dynamic_inner( - listener: TcpListener, - config: GatewayConfig, - dynamic_plugins: Vec, - shutdown_mode: Option, -) -> Result<(), CliError> { - let plugin_activation = - initialize_plugin_host(config.plugin_config.clone(), dynamic_plugins).await?; - let state = AppState::new(config); - let sessions = state.sessions.clone(); - let last_activity = state.last_activity.clone(); - let app = router_with_state(state); - let idle_shutdown = matches!(&shutdown_mode, None | Some(ShutdownMode::ProcessSignal)) - .then(plugin_idle_timeout) - .flatten() - .map(|timeout| idle_shutdown_future(last_activity, sessions.clone(), timeout)); - let shutdown: Option = match shutdown_mode { - Some(ShutdownMode::Receiver(receiver)) => Some(Box::pin(async move { - let _ = receiver.await; - })), - Some(ShutdownMode::ProcessSignal) => Some(Box::pin(async move { - if let Some(idle) = idle_shutdown { - tokio::select! { - _ = shutdown_signal() => {} - _ = idle => {} - } - } else { - shutdown_signal().await; - } - })), - None => idle_shutdown.map(|idle| Box::pin(idle) as ShutdownFuture), - }; - let serve_result = match shutdown { - Some(shutdown) => { - axum::serve(listener, app) - .with_graceful_shutdown(shutdown) - .await - } - None => axum::serve(listener, app).await, - }; - let close_result = sessions.close_all("gateway_shutdown").await; - let flush_result = nemo_relay::api::runtime::flush_subscribers().map_err(CliError::from); - let clear_result = plugin_activation - .map(ServerPluginActivation::clear) - .unwrap_or(Ok(())); - if let Err(serve_error) = serve_result { - if let Err(close_error) = close_result { - eprintln!("session teardown failed after server error: {close_error}"); - } - if let Err(flush_error) = flush_result { - eprintln!("subscriber flush failed after server error: {flush_error}"); - } - if let Err(clear_error) = clear_result { - eprintln!("plugin teardown failed after server error: {clear_error}"); - } - return Err(serve_error.into()); - } - close_result?; - flush_result?; - clear_result -} - -async fn shutdown_signal() { - #[cfg(unix)] - { - let mut terminate = - tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) - .expect("installing SIGTERM handler should succeed"); - tokio::select! { - _ = tokio::signal::ctrl_c() => {} - _ = terminate.recv() => {} - } - } - - #[cfg(windows)] - { - let mut ctrl_shutdown = tokio::signal::windows::ctrl_shutdown() - .expect("installing Windows shutdown handler should succeed"); - tokio::select! { - _ = tokio::signal::ctrl_c() => {} - _ = ctrl_shutdown.recv() => {} - } - } - - #[cfg(not(any(unix, windows)))] - { - let _ = tokio::signal::ctrl_c().await; - } -} - -/// Builds the gateway HTTP router and shared state. -/// -/// Hook endpoints normalize agent-specific payloads into session events, while gateway endpoints -/// proxy model traffic and emit LLM runtime events against the same `SessionManager`. -#[cfg(test)] -pub(crate) fn router(config: GatewayConfig) -> Router { - router_with_state(AppState::new(config)) -} - -impl AppState { - fn new(config: GatewayConfig) -> Self { - let sessions = SessionManager::new(config.clone()); - sessions.start_idle_sweeper(); - let http = Client::builder() - .connect_timeout(HTTP_CONNECT_TIMEOUT) - .timeout(HTTP_REQUEST_TIMEOUT) - .read_timeout(HTTP_READ_TIMEOUT) - .build() - .expect("gateway HTTP client configuration is valid"); - Self { - config, - http, - sessions, - last_activity: Arc::new(Mutex::new(Instant::now())), - } - } - - pub(crate) fn touch(&self) { - if let Ok(mut last_activity) = self.last_activity.lock() { - *last_activity = Instant::now(); - } - } -} - -fn router_with_state(state: AppState) -> Router { - let max_hook_payload_bytes = state.config.max_hook_payload_bytes; - Router::new() - .route("/healthz", get(healthz)) - .route("/hooks/codex", post(codex_hook)) - .route("/hooks/claude-code", post(claude_code_hook)) - .route("/hooks/hermes", post(hermes_hook)) - .route("/responses", post(gateway::passthrough)) - .route("/chat/completions", post(gateway::passthrough)) - .route("/models", get(gateway::models)) - .route("/v1/responses", post(gateway::passthrough)) - .route("/v1/chat/completions", post(gateway::passthrough)) - .route("/v1/messages", post(gateway::passthrough)) - .route("/v1/messages/count_tokens", post(gateway::passthrough)) - .route("/v1/models", get(gateway::models)) - .layer(DefaultBodyLimit::max(max_hook_payload_bytes)) - .with_state(state) -} - -async fn healthz(State(state): State) -> Json { - state.touch(); - Json(serde_json::json!({ "status": "ok" })) -} - -fn plugin_idle_timeout() -> Option { - let raw = std::env::var("NEMO_RELAY_PLUGIN_IDLE_TIMEOUT_SECS").ok()?; - let seconds = raw.parse::().ok()?; - (seconds > 0).then(|| Duration::from_secs(seconds)) -} - -async fn idle_shutdown_future( - last_activity: Arc>, - sessions: SessionManager, - timeout: Duration, -) { - let tick = timeout - .min(Duration::from_secs(5)) - .max(Duration::from_secs(1)); - loop { - tokio::time::sleep(tick).await; - let elapsed = last_activity - .lock() - .map(|last_activity| last_activity.elapsed()) - .unwrap_or(timeout); - if elapsed >= timeout && !sessions.has_open_sessions().await { - break; - } - } -} - -enum ServerPluginActivation { - Static, - Dynamic(PluginHostActivation), -} - -impl ServerPluginActivation { - fn clear(self) -> Result<(), CliError> { - match self { - Self::Static => clear_plugin_configuration() - .map_err(|error| CliError::Config(format!("plugin teardown failed: {error}"))), - Self::Dynamic(activation) => activation - .clear() - .map_err(|error| CliError::Config(format!("plugin teardown failed: {error}"))), - } - } -} - -async fn initialize_plugin_host( - config: Option, - dynamic_plugins: Vec, -) -> Result, CliError> { - if config.is_none() && dynamic_plugins.is_empty() { - return Ok(None); - } - register_adaptive_component().map_err(|error| { - CliError::Config(format!("adaptive plugin registration failed: {error}")) - })?; - register_pii_redaction_component().map_err(|error| { - CliError::Config(format!("PII redaction plugin registration failed: {error}")) - })?; - #[cfg(feature = "switchyard")] - register_switchyard_component().map_err(|error| { - CliError::Config(format!("Switchyard plugin registration failed: {error}")) - })?; - let plugin_config: PluginConfig = match config { - Some(config) => serde_json::from_value(config) - .map_err(|error| CliError::Config(format!("invalid plugin config: {error}")))?, - None => PluginConfig::default(), - }; - #[cfg(feature = "switchyard")] - validate_switchyard_atof_configuration(&plugin_config) - .map_err(|error| CliError::Config(format!("Switchyard ATOF validation failed: {error}")))?; - if dynamic_plugins.is_empty() { - initialize_plugins_exact(plugin_config) - .await - .map_err(|error| CliError::Config(format!("plugin activation failed: {error}")))?; - return Ok(Some(ServerPluginActivation::Static)); - } - let specs = dynamic_plugins - .into_iter() - .map(|plugin| { - let manifest_ref = plugin.manifest_ref.ok_or_else(|| { - CliError::Config(format!( - "dynamic plugin '{}' has no manifest_ref in lifecycle state", - plugin.plugin_id - )) - })?; - Ok(DynamicPluginActivationSpec { - plugin_id: plugin.plugin_id, - kind: plugin.kind, - manifest_ref, - environment_ref: plugin.environment_ref, - config: plugin.config, - }) - }) - .collect::, CliError>>()?; - let (activation, _) = PluginHostActivation::activate(plugin_config, specs) - .await - .map_err(|error| CliError::Config(format!("plugin activation failed: {error}")))?; - Ok(Some(ServerPluginActivation::Dynamic(activation))) -} - -// Normalizes a Codex hook payload, applies all resulting events before responding, and returns the -// adapter's pass-through response body so hook delivery stays causally ordered with observability. -async fn codex_hook( - State(state): State, - headers: HeaderMap, - payload: Result, JsonRejection>, -) -> Result, CliError> { - state.touch(); - let Json(payload) = payload.map_err(hook_payload_rejection)?; - let outcome = codex::adapt(payload, &headers); - state - .sessions - .apply_events(&headers, outcome.events) - .await?; - Ok(Json(outcome.response)) -} - -// Handles Claude Code hooks with the adapter's explicit continuation/permission response. Events -// are committed before the response so Claude lifecycle hooks can close scopes deterministically. -async fn claude_code_hook( - State(state): State, - headers: HeaderMap, - payload: Result, JsonRejection>, -) -> Result, CliError> { - state.touch(); - let Json(payload) = payload.map_err(hook_payload_rejection)?; - let outcome = claude_code::adapt(payload, &headers); - state - .sessions - .apply_events(&headers, outcome.events) - .await?; - Ok(Json(outcome.response)) -} - -// Handles Hermes hook payloads from persistent shell integration. The adapter returns a minimal -// body because hook-forward owns the fail-open/fail-closed behavior for Hermes command execution. -async fn hermes_hook( - State(state): State, - headers: HeaderMap, - payload: Result, JsonRejection>, -) -> Result, CliError> { - state.touch(); - let Json(payload) = payload.map_err(hook_payload_rejection)?; - let outcome = hermes::adapt(payload, &headers); - state - .sessions - .apply_events(&headers, outcome.events) - .await?; - Ok(Json(outcome.response)) -} - -fn hook_payload_rejection(rejection: JsonRejection) -> CliError { - if rejection.status() == axum::http::StatusCode::PAYLOAD_TOO_LARGE { - CliError::PayloadTooLarge(rejection.to_string()) - } else { - CliError::InvalidPayload(rejection.to_string()) - } -} - -#[cfg(test)] -#[path = "../tests/coverage/server_tests.rs"] -mod tests; diff --git a/crates/cli/src/server/mod.rs b/crates/cli/src/server/mod.rs new file mode 100644 index 000000000..313f6ef65 --- /dev/null +++ b/crates/cli/src/server/mod.rs @@ -0,0 +1,1064 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +mod types; + +pub(crate) use types::GatewayOverrides; + +use std::future::Future; +use std::net::SocketAddr; +use std::path::Path; +use std::pin::Pin; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use axum::body::Body; +use axum::extract::rejection::JsonRejection; +use axum::extract::{DefaultBodyLimit, State}; +use axum::http::{HeaderMap, HeaderValue, Request, StatusCode}; +use axum::response::{IntoResponse, Response}; +use axum::routing::{get, post}; +use axum::{Json, Router}; +use nemo_relay::plugin::dynamic::{ + DynamicPluginKind, NativePluginActivation, NativePluginLoadSpec, WorkerPluginActivation, + WorkerPluginLoadSpec, load_native_plugins, load_worker_plugins, +}; +use nemo_relay::plugin::{ + PluginComponentSpec, PluginConfig, clear_plugin_configuration, initialize_plugins_exact, +}; +use nemo_relay_adaptive::plugin_component::register_adaptive_component; +use nemo_relay_pii_redaction::component::register_pii_redaction_component; +#[cfg(feature = "switchyard")] +use nemo_relay_switchyard::{ + register_switchyard_component, validate_switchyard_atof_configuration, +}; +use reqwest::Client; +use serde_json::Value; +use subtle::ConstantTimeEq; +use tokio::net::TcpListener; +use tokio::sync::oneshot; + +use crate::agents::shared::adapters::{claude_code, codex, hermes}; +use crate::configuration::{ + BOOTSTRAP_CLIENT_TOKEN_HEADER, BootstrapChallengeKey, GatewayConfig, ManagedBootstrapIdentity, +}; +use crate::error::CliError; +use crate::gateway; +use crate::plugins::lifecycle::{ActiveDynamicPluginComponent, DynamicPluginActivationSnapshot}; +use crate::sessions::SessionManager; + +const HTTP_CONNECT_TIMEOUT: Duration = Duration::from_secs(30); +const HTTP_REQUEST_TIMEOUT: Duration = Duration::from_secs(300); +const HTTP_READ_TIMEOUT: Duration = Duration::from_secs(300); + +#[derive(Clone)] +pub(crate) struct AppState { + pub(crate) config: GatewayConfig, + pub(crate) bootstrap_fingerprint: Option, + pub(crate) bootstrap_challenge_key: Option, + pub(crate) require_provider_client_token: bool, + pub(crate) http: Client, + pub(crate) sessions: SessionManager, + pub(crate) last_activity: Arc>, + pub(crate) bootstrap_shutdown: Option, + pub(crate) instance_id: String, + pub(crate) bootstrap_tls: Option>, + pub(crate) local_address: Option, +} + +#[derive(Clone)] +pub(crate) struct BootstrapShutdown { + token: String, + sender: Arc>>>, +} + +#[derive(Default)] +struct BootstrapServeOptions<'a> { + fingerprint: Option, + identity: Option, + ready_file: Option<&'a Path>, + shutdown_token: Option, +} + +/// Binds the configured address and activates enabled dynamic plugins before serving. +pub(crate) async fn serve_with_dynamic( + config: GatewayConfig, + dynamic_plugins: Vec, + managed_bootstrap: Option, + ready_file: Option<&Path>, + bootstrap_shutdown_token: Option, +) -> Result<(), CliError> { + let listener = bind_listener(config.bind).await?; + print_startup_status(listener.local_addr()?, &config); + let bootstrap_fingerprint = managed_bootstrap + .as_ref() + .map(|identity| identity.fingerprint().to_owned()); + serve_listener_with_dynamic_inner( + listener, + config, + dynamic_plugins, + Some(ShutdownMode::ProcessSignal), + BootstrapServeOptions { + fingerprint: bootstrap_fingerprint, + identity: managed_bootstrap, + ready_file, + shutdown_token: bootstrap_shutdown_token, + }, + ) + .await +} + +/// Binds a gateway listener and translates address conflicts into actionable diagnostics. +pub(crate) async fn bind_listener(bind: SocketAddr) -> Result { + TcpListener::bind(bind).await.map_err(|err| { + // Translate the common bind-failure (port already in use) into an actionable message. + // Plain `io error: Address already in use (os error 48)` is unhelpful; the friendly + // version names the likely cause and points at the real fixes. + if err.kind() == std::io::ErrorKind::AddrInUse { + CliError::Launch(format!( + "cannot bind {} — port is already in use. Most likely cause: another \ + `nemo-relay` daemon is already running. Fix one of:\n \ + • use the managed shutdown command, or identify the owning daemon PID and \ + terminate only that process\n \ + • use an ephemeral port: `nemo-relay --bind 127.0.0.1:0`\n \ + • pick a free port: `nemo-relay --bind 127.0.0.1:4041`", + bind + )) + } else { + CliError::Io(err) + } + }) +} + +pub(crate) fn print_startup_status(bind: SocketAddr, config: &GatewayConfig) { + let use_color = std::io::IsTerminal::is_terminal(&std::io::stderr()) + && std::env::var_os("NO_COLOR").is_none(); + eprint!("{}", render_startup_status(bind, config, use_color)); +} + +fn render_startup_status(bind: SocketAddr, config: &GatewayConfig, color: bool) -> String { + let mut lines = vec![ + "NeMo Relay".to_string(), + format!(" Gateway http://{bind}"), + ]; + let destinations = crate::process::launcher::exporter_destinations(config); + if destinations.is_empty() { + lines.push(" Exporters not configured".into()); + } else { + for (index, destination) in destinations.iter().enumerate() { + lines.push(format!( + " {}{}", + if index == 0 { + "Exporters " + } else { + " " + }, + destination + )); + } + } + + crate::process::launcher::render_status_frame(&lines, color) +} + +/// Serves the gateway router on a caller-owned listener with optional graceful shutdown. +/// +/// A provided shutdown receiver is best-effort: the send side may be dropped after the child agent +/// exits, and either receiving or channel closure is enough to let Axum drain the listener. +#[cfg(test)] +pub(crate) async fn serve_listener( + listener: TcpListener, + config: GatewayConfig, + shutdown: Option>, +) -> Result<(), CliError> { + serve_listener_with_dynamic(listener, config, Vec::new(), shutdown).await +} + +#[cfg(test)] +pub(crate) async fn serve_listener_with_bootstrap( + listener: TcpListener, + config: GatewayConfig, + bootstrap_fingerprint: String, + shutdown: Option>, +) -> Result<(), CliError> { + serve_listener_with_dynamic_inner( + listener, + config, + Vec::new(), + shutdown.map(ShutdownMode::Receiver), + BootstrapServeOptions { + fingerprint: Some(bootstrap_fingerprint), + ..BootstrapServeOptions::default() + }, + ) + .await +} + +/// Serves the gateway router and activates enabled dynamic plugin components. +#[cfg(test)] +pub(crate) async fn serve_listener_with_dynamic( + listener: TcpListener, + config: GatewayConfig, + dynamic_plugins: Vec, + shutdown: Option>, +) -> Result<(), CliError> { + serve_listener_with_dynamic_inner( + listener, + config, + dynamic_plugins, + shutdown.map(ShutdownMode::Receiver), + BootstrapServeOptions::default(), + ) + .await +} + +/// Serves a wrapper-owned dynamic gateway with authenticated health while keeping foreground +/// provider-auth semantics. Plugin-owned MCP clients use the proof to borrow only this instance. +pub(crate) async fn serve_transparent_listener_with_dynamic( + listener: TcpListener, + config: GatewayConfig, + dynamic_plugins: Vec, + bootstrap_fingerprint: String, + shutdown: Option>, +) -> Result<(), CliError> { + serve_listener_with_dynamic_inner( + listener, + config, + dynamic_plugins, + shutdown.map(ShutdownMode::Receiver), + BootstrapServeOptions { + fingerprint: Some(bootstrap_fingerprint), + ..BootstrapServeOptions::default() + }, + ) + .await +} + +type ShutdownFuture = Pin + Send>>; + +enum ShutdownMode { + Receiver(oneshot::Receiver<()>), + ProcessSignal, +} + +async fn serve_listener_with_dynamic_inner( + listener: TcpListener, + config: GatewayConfig, + dynamic_plugins: Vec, + shutdown_mode: Option, + bootstrap: BootstrapServeOptions<'_>, +) -> Result<(), CliError> { + let BootstrapServeOptions { + fingerprint: bootstrap_fingerprint, + identity: managed_bootstrap, + ready_file, + shutdown_token: bootstrap_shutdown_token, + } = bootstrap; + let bootstrap_challenge_key = bootstrap_fingerprint + .as_ref() + .map(|_| BootstrapChallengeKey::load()) + .transpose()?; + let bootstrap_tls = bootstrap_fingerprint + .as_ref() + .map(|_| crate::gateway::tls::RelayTlsIdentity::load_or_create()) + .transpose() + .map_err(CliError::Launch)? + .map(|identity| identity.server_config()) + .transpose() + .map_err(CliError::Launch)?; + let require_provider_client_token = managed_bootstrap.is_some(); + let plugin_activation = + initialize_plugin_host(config.plugin_config.clone(), dynamic_plugins).await?; + let (bootstrap_shutdown, bootstrap_shutdown_rx) = + bootstrap_shutdown_channel(bootstrap_shutdown_token.clone()); + let mut state = AppState::new_with_bootstrap( + config, + bootstrap_fingerprint, + bootstrap_challenge_key, + require_provider_client_token, + bootstrap_shutdown, + ); + state.bootstrap_tls = bootstrap_tls; + state.local_address = Some(listener.local_addr()?); + let instance_id = state.instance_id.clone(); + let sessions = state.sessions.clone(); + let last_activity = state.last_activity.clone(); + let app = router_with_state(state); + let local_address = listener.local_addr()?; + if let Some(identity) = managed_bootstrap.as_ref() { + identity.verify_current()?; + } + let _owner = crate::bootstrap::state::publish_owner_from_env( + local_address, + bootstrap_shutdown_token.as_deref(), + ) + .map_err(CliError::Launch)?; + if let Some(path) = ready_file { + write_ready_file(path, local_address, &instance_id)?; + } + let idle_shutdown = if matches!(&shutdown_mode, None | Some(ShutdownMode::ProcessSignal)) { + plugin_idle_timeout()? + .map(|timeout| idle_shutdown_future(last_activity, sessions.clone(), timeout)) + } else { + None + }; + let shutdown: Option = match shutdown_mode { + Some(ShutdownMode::Receiver(receiver)) => Some(Box::pin(async move { + let _ = receiver.await; + })), + Some(ShutdownMode::ProcessSignal) => Some(Box::pin(async move { + if let Some(idle) = idle_shutdown { + tokio::select! { + _ = shutdown_signal() => {} + _ = idle => {} + } + } else { + shutdown_signal().await; + } + })), + None => idle_shutdown.map(|idle| Box::pin(idle) as ShutdownFuture), + }; + let shutdown = match (shutdown, bootstrap_shutdown_rx) { + (Some(shutdown), Some(receiver)) => Some(Box::pin(async move { + tokio::select! { + _ = shutdown => {} + _ = receiver => {} + } + }) as ShutdownFuture), + (None, Some(receiver)) => Some(Box::pin(async move { + let _ = receiver.await; + }) as ShutdownFuture), + (shutdown, None) => shutdown, + }; + let serve_result = match shutdown { + Some(shutdown) => { + axum::serve(listener, app) + .with_graceful_shutdown(shutdown) + .await + } + None => axum::serve(listener, app).await, + }; + let close_result = sessions.close_all("gateway_shutdown").await; + let flush_result = nemo_relay::api::runtime::flush_subscribers().map_err(CliError::from); + let clear_result = plugin_activation + .map(ServerPluginActivation::clear) + .unwrap_or(Ok(())); + if let Err(serve_error) = serve_result { + if let Err(close_error) = close_result { + eprintln!("session teardown failed after server error: {close_error}"); + } + if let Err(flush_error) = flush_result { + eprintln!("subscriber flush failed after server error: {flush_error}"); + } + if let Err(clear_error) = clear_result { + eprintln!("plugin teardown failed after server error: {clear_error}"); + } + return Err(serve_error.into()); + } + close_result?; + flush_result?; + clear_result +} + +async fn shutdown_signal() { + #[cfg(unix)] + { + let mut terminate = + tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) + .expect("installing SIGTERM handler should succeed"); + tokio::select! { + _ = tokio::signal::ctrl_c() => {} + _ = terminate.recv() => {} + } + } + + #[cfg(windows)] + { + let mut ctrl_shutdown = tokio::signal::windows::ctrl_shutdown() + .expect("installing Windows shutdown handler should succeed"); + tokio::select! { + _ = tokio::signal::ctrl_c() => {} + _ = ctrl_shutdown.recv() => {} + } + } + + #[cfg(not(any(unix, windows)))] + { + let _ = tokio::signal::ctrl_c().await; + } +} + +/// Builds the gateway HTTP router and shared state. +/// +/// Hook endpoints normalize agent-specific payloads into session events, while gateway endpoints +/// proxy model traffic and emit LLM runtime events against the same `SessionManager`. +#[cfg(test)] +pub(crate) fn router(config: GatewayConfig) -> Router { + router_with_state(AppState::new(config)) +} + +impl AppState { + #[cfg(test)] + pub(crate) fn new(config: GatewayConfig) -> Self { + Self::new_with_bootstrap(config, None, None, false, None) + } + + fn new_with_bootstrap( + config: GatewayConfig, + bootstrap_fingerprint: Option, + bootstrap_challenge_key: Option, + require_provider_client_token: bool, + bootstrap_shutdown: Option, + ) -> Self { + let sessions = SessionManager::new(config.clone()); + sessions.start_idle_sweeper(); + let http = Client::builder() + .connect_timeout(HTTP_CONNECT_TIMEOUT) + .timeout(HTTP_REQUEST_TIMEOUT) + .read_timeout(HTTP_READ_TIMEOUT) + .build() + .expect("gateway HTTP client configuration is valid"); + Self { + config, + bootstrap_fingerprint, + bootstrap_challenge_key, + require_provider_client_token, + http, + sessions, + last_activity: Arc::new(Mutex::new(Instant::now())), + bootstrap_shutdown, + instance_id: uuid::Uuid::now_v7().to_string(), + bootstrap_tls: None, + local_address: None, + } + } + + pub(crate) fn touch(&self) { + if let Ok(mut last_activity) = self.last_activity.lock() { + *last_activity = Instant::now(); + } + } + + /// Foreground gateways may supply provider credentials from their own environment for simple + /// local proxy use. Managed plugin sidecars are long-lived loopback services, so callers must + /// present the private per-user proof installed into their provider configuration before Relay + /// can spend a forwarded credential on their behalf. + pub(crate) fn allows_environment_provider_auth(&self, headers: &HeaderMap) -> bool { + if !self.require_provider_client_token { + return true; + } + let Some(key) = self.bootstrap_challenge_key.as_ref() else { + return false; + }; + headers + .get(BOOTSTRAP_CLIENT_TOKEN_HEADER) + .and_then(|value| value.to_str().ok()) + .is_some_and(|token| key.verify_client_token(token)) + } +} + +fn router_with_state(state: AppState) -> Router { + let max_hook_payload_bytes = state.config.max_hook_payload_bytes; + Router::new() + .route("/healthz", get(healthz)) + .route("/bootstrap/tunnel", get(bootstrap_tls_tunnel)) + .route("/bootstrap/shutdown", post(shutdown_bootstrap_sidecar)) + .route("/hooks/codex", post(codex_hook)) + .route("/hooks/claude-code", post(claude_code_hook)) + .route("/hooks/hermes", post(hermes_hook)) + .route("/responses", post(gateway::passthrough)) + .route("/chat/completions", post(gateway::passthrough)) + .route("/models", get(gateway::models)) + .route("/v1/responses", post(gateway::passthrough)) + .route("/v1/chat/completions", post(gateway::passthrough)) + .route("/v1/messages", post(gateway::passthrough)) + .route("/v1/messages/count_tokens", post(gateway::passthrough)) + .route("/v1/models", get(gateway::models)) + .layer(DefaultBodyLimit::max(max_hook_payload_bytes)) + .with_state(state) +} + +async fn bootstrap_tls_tunnel( + State(state): State, + mut request: Request, +) -> Response { + let headers = request.headers(); + let Some(fingerprint) = headers + .get("x-nemo-relay-bootstrap-fingerprint") + .and_then(|value| value.to_str().ok()) + else { + return StatusCode::FORBIDDEN.into_response(); + }; + let Some(nonce) = headers + .get("x-nemo-relay-bootstrap-nonce") + .and_then(|value| value.to_str().ok()) + .filter(|nonce| nonce.len() == 64 && nonce.bytes().all(|byte| byte.is_ascii_hexdigit())) + else { + return StatusCode::FORBIDDEN.into_response(); + }; + let fingerprint_matches = state + .bootstrap_fingerprint + .as_deref() + .is_some_and(|actual| bool::from(actual.as_bytes().ct_eq(fingerprint.as_bytes()))); + let (Some(key), Some(tls), Some(local_address)) = ( + state.bootstrap_challenge_key.as_ref(), + state.bootstrap_tls.clone(), + state.local_address, + ) else { + return StatusCode::NOT_FOUND.into_response(); + }; + if !fingerprint_matches + || headers + .get(http::header::UPGRADE) + .and_then(|value| value.to_str().ok()) + != Some("nemo-relay-tls") + { + return StatusCode::FORBIDDEN.into_response(); + } + let proof = key.proof(fingerprint, nonce); + let upgrade = hyper::upgrade::on(&mut request); + tokio::spawn(async move { + let Ok(upgraded) = upgrade.await else { + return; + }; + let acceptor = tokio_rustls::TlsAcceptor::from(tls); + let Ok(mut encrypted) = acceptor + .accept(hyper_util::rt::TokioIo::new(upgraded)) + .await + else { + return; + }; + let Ok(mut local) = tokio::net::TcpStream::connect(local_address).await else { + return; + }; + let _ = tokio::io::copy_bidirectional(&mut encrypted, &mut local).await; + }); + Response::builder() + .status(StatusCode::SWITCHING_PROTOCOLS) + .header(http::header::CONNECTION, "upgrade") + .header(http::header::UPGRADE, "nemo-relay-tls") + .header("x-nemo-relay-bootstrap-proof", proof) + .header(http::header::CONTENT_LENGTH, "0") + .body(Body::empty()) + .expect("bootstrap TLS upgrade response is valid") +} + +fn bootstrap_shutdown_channel( + token: Option, +) -> (Option, Option>) { + let Some(token) = token else { + return (None, None); + }; + let (sender, receiver) = oneshot::channel(); + ( + Some(BootstrapShutdown { + token, + sender: Arc::new(Mutex::new(Some(sender))), + }), + Some(receiver), + ) +} + +async fn shutdown_bootstrap_sidecar( + State(state): State, + headers: HeaderMap, +) -> StatusCode { + let Some(shutdown) = state.bootstrap_shutdown.as_ref() else { + return StatusCode::NOT_FOUND; + }; + if headers + .get("x-nemo-relay-bootstrap-token") + .and_then(|value| value.to_str().ok()) + != Some(shutdown.token.as_str()) + { + return StatusCode::FORBIDDEN; + } + let Ok(mut sender) = shutdown.sender.lock() else { + return StatusCode::INTERNAL_SERVER_ERROR; + }; + let Some(sender) = sender.take() else { + return StatusCode::GONE; + }; + let _ = sender.send(()); + StatusCode::NO_CONTENT +} + +async fn healthz(State(state): State, headers: HeaderMap) -> Response { + let presented_fingerprint = headers + .get("x-nemo-relay-bootstrap-fingerprint") + .and_then(|value| value.to_str().ok()); + let mut response_headers = HeaderMap::new(); + let compatible = match presented_fingerprint { + None => true, + Some(expected) => { + let fingerprint_matches = state + .bootstrap_fingerprint + .as_deref() + .is_some_and(|actual| bool::from(actual.as_bytes().ct_eq(expected.as_bytes()))); + let nonce = headers + .get("x-nemo-relay-bootstrap-nonce") + .and_then(|value| value.to_str().ok()) + .filter(|nonce| { + nonce.len() == 64 && nonce.bytes().all(|byte| byte.is_ascii_hexdigit()) + }); + match ( + fingerprint_matches, + nonce, + state.bootstrap_challenge_key.as_ref(), + ) { + (true, Some(nonce), Some(key)) => { + let proof = key.proof(expected, nonce); + response_headers.insert( + "x-nemo-relay-bootstrap-proof", + HeaderValue::from_str(&proof).expect("bootstrap proof is an ASCII value"), + ); + state.touch(); + true + } + _ => false, + } + } + }; + ( + if compatible { + StatusCode::OK + } else { + StatusCode::CONFLICT + }, + response_headers, + Json(serde_json::json!({ + "status": if compatible { "ok" } else { "incompatible" }, + "service": "nemo-relay", + "version": env!("CARGO_PKG_VERSION"), + "bootstrap_protocol": crate::bootstrap::BOOTSTRAP_PROTOCOL_VERSION, + "instance_id": state.instance_id, + })), + ) + .into_response() +} + +fn write_ready_file(path: &Path, bind: SocketAddr, instance_id: &str) -> Result<(), CliError> { + let bytes = serde_json::to_vec(&serde_json::json!({ + "address": bind, + "service": "nemo-relay", + "version": env!("CARGO_PKG_VERSION"), + "bootstrap_protocol": crate::bootstrap::BOOTSTRAP_PROTOCOL_VERSION, + "instance_id": instance_id, + })) + .map_err(|error| CliError::Launch(format!("failed to encode readiness file: {error}")))?; + let temporary = path.with_extension(format!( + "{}tmp", + path.extension() + .and_then(|extension| extension.to_str()) + .map(|extension| format!("{extension}.")) + .unwrap_or_default() + )); + std::fs::write(&temporary, bytes).map_err(|error| { + CliError::Launch(format!( + "failed to write readiness file {}: {error}", + temporary.display() + )) + })?; + std::fs::rename(&temporary, path).map_err(|error| { + let _ = std::fs::remove_file(&temporary); + CliError::Launch(format!( + "failed to publish readiness file {}: {error}", + path.display() + )) + }) +} + +fn plugin_idle_timeout() -> Result, CliError> { + let Some(raw) = std::env::var("NEMO_RELAY_PLUGIN_IDLE_TIMEOUT_SECS").ok() else { + return Ok(None); + }; + let seconds = raw.parse::().map_err(|error| { + CliError::Config(format!( + "NEMO_RELAY_PLUGIN_IDLE_TIMEOUT_SECS must be a positive integer: {error}" + )) + })?; + if seconds == 0 { + return Err(CliError::Config( + "NEMO_RELAY_PLUGIN_IDLE_TIMEOUT_SECS must be greater than 0".into(), + )); + } + Ok(Some(Duration::from_secs(seconds))) +} + +async fn idle_shutdown_future( + last_activity: Arc>, + sessions: SessionManager, + timeout: Duration, +) { + let tick = timeout + .min(Duration::from_secs(5)) + .max(Duration::from_secs(1)); + loop { + tokio::time::sleep(tick).await; + if idle_shutdown_ready(&last_activity, timeout, sessions.has_open_sessions()).await { + break; + } + } +} + +async fn idle_shutdown_ready( + last_activity: &Arc>, + timeout: Duration, + has_open_sessions: F, +) -> bool +where + F: std::future::Future, +{ + let observed = match last_activity.lock() { + Ok(last_activity) if last_activity.elapsed() >= timeout => *last_activity, + Ok(_) => return false, + Err(_) => return true, + }; + if has_open_sessions.await { + return false; + } + last_activity.lock().map_or(true, |last_activity| { + *last_activity == observed && last_activity.elapsed() >= timeout + }) +} + +enum ServerPluginActivation { + Static, + Dynamic(PluginActivation), +} + +impl ServerPluginActivation { + fn clear(self) -> Result<(), CliError> { + match self { + Self::Static => clear_plugin_configuration() + .map_err(|error| CliError::Config(format!("plugin teardown failed: {error}"))), + Self::Dynamic(activation) => activation.clear(), + } + } +} + +#[derive(Debug)] +pub(crate) enum PluginComponentSetupError { + Adaptive(String), + PiiRedaction(String), + #[cfg(feature = "switchyard")] + Switchyard(String), + #[cfg(feature = "switchyard")] + SwitchyardAtof(String), +} + +impl PluginComponentSetupError { + pub(crate) const fn check_name(&self) -> &'static str { + match self { + Self::Adaptive(_) => "Adaptive plugin", + Self::PiiRedaction(_) => "PII redaction plugin", + #[cfg(feature = "switchyard")] + Self::Switchyard(_) => "Switchyard plugin", + #[cfg(feature = "switchyard")] + Self::SwitchyardAtof(_) => "Switchyard ATOF", + } + } + + pub(crate) fn diagnostic_details(&self) -> String { + match self { + Self::Adaptive(error) | Self::PiiRedaction(error) => { + format!("registration failed: {error}") + } + #[cfg(feature = "switchyard")] + Self::Switchyard(error) => format!("registration failed: {error}"), + #[cfg(feature = "switchyard")] + Self::SwitchyardAtof(error) => error.clone(), + } + } +} + +impl std::fmt::Display for PluginComponentSetupError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Adaptive(error) => { + write!(formatter, "adaptive plugin registration failed: {error}") + } + Self::PiiRedaction(error) => { + write!( + formatter, + "PII redaction plugin registration failed: {error}" + ) + } + #[cfg(feature = "switchyard")] + Self::Switchyard(error) => { + write!(formatter, "Switchyard plugin registration failed: {error}") + } + #[cfg(feature = "switchyard")] + Self::SwitchyardAtof(error) => { + write!(formatter, "Switchyard ATOF validation failed: {error}") + } + } + } +} + +pub(crate) fn register_and_validate_plugin_components( + _plugin_config: &PluginConfig, +) -> Vec { + let mut errors = Vec::new(); + if let Err(error) = register_adaptive_component() { + errors.push(PluginComponentSetupError::Adaptive(error.to_string())); + } + if let Err(error) = register_pii_redaction_component() { + errors.push(PluginComponentSetupError::PiiRedaction(error.to_string())); + } + #[cfg(feature = "switchyard")] + if let Err(error) = register_switchyard_component() { + errors.push(PluginComponentSetupError::Switchyard(error.to_string())); + } + #[cfg(feature = "switchyard")] + if let Err(error) = validate_switchyard_atof_configuration(_plugin_config) { + errors.push(PluginComponentSetupError::SwitchyardAtof(error)); + } + errors +} + +async fn initialize_plugin_host( + config: Option, + dynamic_plugins: Vec, +) -> Result, CliError> { + if config.is_none() && dynamic_plugins.is_empty() { + return Ok(None); + } + if dynamic_plugins.is_empty() { + let plugin_config: PluginConfig = config + .map(serde_json::from_value) + .transpose() + .map_err(|error| CliError::Config(format!("invalid plugin config: {error}")))? + .unwrap_or_default(); + if let Some(error) = register_and_validate_plugin_components(&plugin_config) + .into_iter() + .next() + { + return Err(CliError::Config(error.to_string())); + } + initialize_plugins_exact(plugin_config) + .await + .map_err(|error| CliError::Config(format!("plugin activation failed: {error}")))?; + return Ok(Some(ServerPluginActivation::Static)); + } + PluginActivation::initialize(config, dynamic_plugins) + .await + .map(ServerPluginActivation::Dynamic) + .map(Some) +} + +struct PluginActivation { + active: bool, + native: Option, + worker: Option, + _snapshots: Vec>, +} + +impl PluginActivation { + async fn initialize( + config: Option, + dynamic_plugins: Vec, + ) -> Result { + if config.is_none() && dynamic_plugins.is_empty() { + return Ok(Self { + active: false, + native: None, + worker: None, + _snapshots: Vec::new(), + }); + }; + // Gateway already resolved its config; activate exactly (no re-discovery). + let mut plugin_config: PluginConfig = match config { + Some(config) => serde_json::from_value(config) + .map_err(|error| CliError::Config(format!("invalid plugin config: {error}")))?, + None => PluginConfig::default(), + }; + plugin_config + .components + .extend(dynamic_plugins.iter().map(|plugin| PluginComponentSpec { + kind: plugin.plugin_id.clone(), + enabled: true, + config: plugin.config.clone(), + })); + if let Some(error) = register_and_validate_plugin_components(&plugin_config) + .into_iter() + .next() + { + return Err(CliError::Config(error.to_string())); + } + for plugin in &dynamic_plugins { + if let Some(snapshot) = plugin.activation_snapshot.as_ref() { + snapshot.verify_current()?; + } + } + let native_specs = dynamic_plugins + .iter() + .filter(|plugin| plugin.kind == DynamicPluginKind::RustDynamic) + .map(|plugin| { + let manifest_ref = plugin + .activation_snapshot + .as_ref() + .map(|snapshot| snapshot.activation_manifest_ref()) + .or_else(|| plugin.manifest_ref.clone()) + .ok_or_else(|| { + CliError::Config(format!( + "native dynamic plugin '{}' has no manifest_ref in lifecycle state", + plugin.plugin_id + )) + })?; + Ok(NativePluginLoadSpec { + plugin_id: plugin.plugin_id.clone(), + manifest_ref, + }) + }) + .collect::, CliError>>()?; + let worker_specs = dynamic_plugins + .iter() + .filter(|plugin| plugin.kind == DynamicPluginKind::Worker) + .map(|plugin| { + let manifest_ref = plugin + .activation_snapshot + .as_ref() + .map(|snapshot| snapshot.activation_manifest_ref()) + .or_else(|| plugin.manifest_ref.clone()) + .ok_or_else(|| { + CliError::Config(format!( + "worker dynamic plugin '{}' has no manifest_ref in lifecycle state", + plugin.plugin_id + )) + })?; + Ok(WorkerPluginLoadSpec { + plugin_id: plugin.plugin_id.clone(), + manifest_ref, + environment_ref: plugin + .activation_snapshot + .as_ref() + .and_then(|snapshot| snapshot.activation_environment_ref()) + .map(ToOwned::to_owned) + .or_else(|| plugin.environment_ref.clone()), + config: plugin.config.clone(), + }) + }) + .collect::, CliError>>()?; + let snapshots = dynamic_plugins + .iter() + .filter_map(|plugin| plugin.activation_snapshot.clone()) + .collect(); + let native = + if native_specs.is_empty() { + None + } else { + Some(load_native_plugins(native_specs).map_err(|error| { + CliError::Config(format!("native plugin load failed: {error}")) + })?) + }; + for plugin in &dynamic_plugins { + if let Some(snapshot) = plugin.activation_snapshot.as_ref() { + snapshot.verify_current()?; + } + } + let worker = + if worker_specs.is_empty() { + None + } else { + Some(load_worker_plugins(worker_specs).map_err(|error| { + CliError::Config(format!("worker plugin load failed: {error}")) + })?) + }; + initialize_plugins_exact(plugin_config) + .await + .map_err(|error| CliError::Config(format!("plugin activation failed: {error}")))?; + Ok(Self { + active: true, + native, + worker, + _snapshots: snapshots, + }) + } + + fn clear(mut self) -> Result<(), CliError> { + let result = if self.active { + self.active = false; + clear_plugin_configuration() + .map_err(|error| CliError::Config(format!("plugin teardown failed: {error}")))?; + Ok(()) + } else { + Ok(()) + }; + self.native.take(); + self.worker.take(); + result + } +} + +impl Drop for PluginActivation { + fn drop(&mut self) { + if self.active { + let _ = clear_plugin_configuration(); + self.active = false; + } + } +} + +// Normalizes a Codex hook payload, applies all resulting events before responding, and returns the +// adapter's pass-through response body so hook delivery stays causally ordered with observability. +async fn codex_hook( + State(state): State, + headers: HeaderMap, + payload: Result, JsonRejection>, +) -> Result, CliError> { + state.touch(); + let Json(payload) = payload.map_err(hook_payload_rejection)?; + let outcome = codex::adapt(payload, &headers); + state + .sessions + .apply_events(&headers, outcome.events) + .await?; + Ok(Json(outcome.response)) +} + +// Handles Claude Code hooks with the adapter's explicit continuation/permission response. Events +// are committed before the response so Claude lifecycle hooks can close scopes deterministically. +async fn claude_code_hook( + State(state): State, + headers: HeaderMap, + payload: Result, JsonRejection>, +) -> Result, CliError> { + state.touch(); + let Json(payload) = payload.map_err(hook_payload_rejection)?; + let outcome = claude_code::adapt(payload, &headers); + state + .sessions + .apply_events(&headers, outcome.events) + .await?; + Ok(Json(outcome.response)) +} + +// Handles Hermes hook payloads from persistent shell integration. The adapter returns a minimal +// body because hook-forward owns the fail-open/fail-closed behavior for Hermes command execution. +async fn hermes_hook( + State(state): State, + headers: HeaderMap, + payload: Result, JsonRejection>, +) -> Result, CliError> { + state.touch(); + let Json(payload) = payload.map_err(hook_payload_rejection)?; + let outcome = hermes::adapt(payload, &headers); + state + .sessions + .apply_events(&headers, outcome.events) + .await?; + Ok(Json(outcome.response)) +} + +fn hook_payload_rejection(rejection: JsonRejection) -> CliError { + if rejection.status() == axum::http::StatusCode::PAYLOAD_TOO_LARGE { + CliError::PayloadTooLarge(rejection.to_string()) + } else { + CliError::InvalidPayload(rejection.to_string()) + } +} + +#[cfg(test)] +#[path = "../../tests/coverage/shared/server_tests.rs"] +mod tests; diff --git a/crates/cli/src/server/types.rs b/crates/cli/src/server/types.rs new file mode 100644 index 000000000..25f63d3c3 --- /dev/null +++ b/crates/cli/src/server/types.rs @@ -0,0 +1,30 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use std::net::SocketAddr; +use std::path::PathBuf; + +#[derive(Debug, Clone, Default)] +pub(crate) struct GatewayOverrides { + pub(crate) config: Option, + pub(crate) bind: Option, + pub(crate) openai_base_url: Option, + pub(crate) anthropic_base_url: Option, + pub(crate) plugin_config_path: Option, + pub(crate) ready_file: Option, + pub(crate) max_hook_payload_bytes: Option, + pub(crate) max_passthrough_body_bytes: Option, +} + +impl GatewayOverrides { + pub(crate) fn requested_daemon_mode(&self) -> bool { + self.bind.is_some() + || self.openai_base_url.is_some() + || self.anthropic_base_url.is_some() + || self.plugin_config_path.is_some() + || self.ready_file.is_some() + || self.max_hook_payload_bytes.is_some() + || self.max_passthrough_body_bytes.is_some() + || self.config.is_some() + } +} diff --git a/crates/cli/src/sessions/correlation.rs b/crates/cli/src/sessions/correlation.rs new file mode 100644 index 000000000..30929e57e --- /dev/null +++ b/crates/cli/src/sessions/correlation.rs @@ -0,0 +1,350 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! LLM/tool hint scoring and gateway ownership correlation. + +use super::*; + +pub(super) fn hint_match_score(hint: &LlmHintEvent, start: &LlmGatewayStart) -> u8 { + let mut score = 0; + if same_optional(hint.subagent_id.as_deref(), start.subagent_id.as_deref()) + || same_optional(hint.agent_id.as_deref(), start.subagent_id.as_deref()) + { + score += 8; + } + if same_optional( + hint.conversation_id.as_deref(), + start.conversation_id.as_deref(), + ) { + score += 4; + } + if same_optional( + hint.generation_id.as_deref(), + start.generation_id.as_deref(), + ) { + score += 4; + } + if same_optional(hint.request_id.as_deref(), start.request_id.as_deref()) { + score += 4; + } + if same_optional(hint.model.as_deref(), start.model_name.as_deref()) { + score += 1; + } + score +} + +// Extracts tool-call hints from common provider response shapes. These private hints let later +// hook-only tool events attach to the subagent that received the LLM response proposing the tool. +pub(super) fn tool_hints_from_llm_response( + response: &Value, + owner_subagent_id: Option, +) -> Vec { + let mut hints = Vec::new(); + collect_openai_chat_tool_hints(response, owner_subagent_id.as_deref(), &mut hints); + collect_openai_response_tool_hints(response, owner_subagent_id.as_deref(), &mut hints); + collect_anthropic_tool_hints(response, owner_subagent_id.as_deref(), &mut hints); + hints +} + +// Collects OpenAI Chat Completions `choices[].message.tool_calls[]` entries and preserves +// stringified function arguments as parsed JSON when possible. +pub(super) fn collect_openai_chat_tool_hints( + response: &Value, + owner_subagent_id: Option<&str>, + hints: &mut Vec, +) { + let Some(choices) = response.get("choices").and_then(Value::as_array) else { + return; + }; + for choice in choices { + let Some(tool_calls) = choice + .get("message") + .and_then(|message| message.get("tool_calls")) + .and_then(Value::as_array) + else { + continue; + }; + for call in tool_calls { + push_tool_hint( + hints, + call, + owner_subagent_id, + "openai_chat_tool_call", + &[&["id"][..], &["call_id"][..]], + &[&["function", "name"][..], &["name"][..]], + &[&["function", "arguments"][..], &["arguments"][..]], + ); + } + } +} + +// Collects OpenAI Responses output items where function-call data is usually direct on each item. +// Items without an id or name are ignored because they are too weak for ownership correlation. +pub(super) fn collect_openai_response_tool_hints( + response: &Value, + owner_subagent_id: Option<&str>, + hints: &mut Vec, +) { + let Some(output) = response.get("output").and_then(Value::as_array) else { + return; + }; + for item in output { + if item.get("type").and_then(Value::as_str) != Some("function_call") { + continue; + } + push_tool_hint( + hints, + item, + owner_subagent_id, + "openai_response_tool_call", + &[&["call_id"][..], &["id"][..]], + &[&["name"][..], &["tool_name"][..]], + &[&["arguments"][..], &["input"][..]], + ); + } +} + +// Collects Anthropic `tool_use` blocks from top-level or nested message content arrays. Other +// content block types are skipped so text and thinking blocks never become tool hints. +pub(super) fn collect_anthropic_tool_hints( + response: &Value, + owner_subagent_id: Option<&str>, + hints: &mut Vec, +) { + for content in [ + response.get("content"), + response + .get("message") + .and_then(|message| message.get("content")), + ] + .into_iter() + .flatten() + .filter_map(Value::as_array) + { + for block in content { + if json_string_at(block, &[&["type"][..]]).as_deref() == Some("tool_use") { + push_tool_hint( + hints, + block, + owner_subagent_id, + "anthropic_tool_use", + &[&["id"][..], &["tool_use_id"][..]], + &[&["name"][..], &["tool_name"][..]], + &[&["input"][..], &["arguments"][..]], + ); + } + } + } +} + +// Appends one provider tool hint when an object carries either a tool-call id or enough +// name-plus-argument data to disambiguate common tool names. Name-only and argument-only hints are +// skipped because they over-match across unrelated tools in parallel coding-agent sessions. +pub(super) fn push_tool_hint( + hints: &mut Vec, + object: &Value, + owner_subagent_id: Option<&str>, + source: &str, + id_paths: &[&[&str]], + name_paths: &[&[&str]], + argument_paths: &[&[&str]], +) { + let tool_call_id = json_string_at(object, id_paths); + let tool_name = json_string_at(object, name_paths); + let arguments = json_value_at(object, argument_paths) + .map(normalize_tool_arguments) + .unwrap_or(Value::Null); + if tool_call_id.is_none() && (tool_name.is_none() || arguments.is_null()) { + return; + } + hints.push(ToolHint { + tool_call_id, + tool_name, + subagent_id: owner_subagent_id.map(ToOwned::to_owned), + arguments, + source: source.to_string(), + }); +} + +// Scores how strongly a pending provider tool hint matches an observed hook event. A shared +// provider call id is strongest. Without an id match, require both tool name and exact arguments so +// repeated coding-agent tool names cannot claim unrelated hooks. +pub(super) fn tool_hint_match_score(hint: &ToolHint, event: &ToolEvent) -> u8 { + let mut score = 0; + let id_matches = same_optional( + hint.tool_call_id.as_deref(), + Some(event.tool_call_id.as_str()), + ); + let name_matches = same_optional(hint.tool_name.as_deref(), Some(event.tool_name.as_str())); + let arguments_match = !hint.arguments.is_null() + && !event.arguments.is_null() + && hint.arguments == event.arguments; + if id_matches { + score += 12; + } + if id_matches && name_matches { + score += 4; + } + if id_matches && arguments_match { + score += 1; + } + if !id_matches && name_matches && arguments_match { + score += 5; + } + score +} + +pub(super) fn same_optional(left: Option<&str>, right: Option<&str>) -> bool { + matches!((left, right), (Some(left), Some(right)) if left == right) +} + +pub(super) fn owner_status_teaches_request_affinity(status: &str) -> bool { + matches!( + status, + "explicit" | "single_hint" | "matched_hint" | "active_subagent" | "request_affinity" + ) +} + +// Parses stringified tool arguments when providers encode them as JSON text. Non-JSON strings are +// preserved as strings so metadata still reflects what the provider actually returned. +pub(super) fn normalize_tool_arguments(arguments: Value) -> Value { + match arguments { + Value::String(raw) => serde_json::from_str(&raw).unwrap_or(Value::String(raw)), + value => value, + } +} + +// Adds correlation status and consumed-hint identifiers to the LLM event metadata. Caller metadata +// is merged first so correlation keys win when names collide. +pub(super) fn llm_correlation_metadata( + metadata: Value, + status: &str, + source: Option<&str>, + subagent_id: Option<&str>, + hint: Option<&LlmHintEvent>, +) -> Value { + let mut correlation = Map::new(); + correlation.insert("llm_correlation_status".into(), json!(status)); + if let Some(source) = source { + correlation.insert("llm_correlation_source".into(), json!(source)); + } + if let Some(subagent_id) = subagent_id { + correlation.insert("llm_correlation_subagent_id".into(), json!(subagent_id)); + } + if let Some(hint) = hint { + insert_optional( + &mut correlation, + "llm_correlation_conversation_id", + hint.conversation_id.as_deref(), + ); + insert_optional( + &mut correlation, + "llm_correlation_generation_id", + hint.generation_id.as_deref(), + ); + insert_optional( + &mut correlation, + "llm_correlation_request_id", + hint.request_id.as_deref(), + ); + insert_optional( + &mut correlation, + "llm_correlation_agent_type", + hint.agent_type.as_deref(), + ); + } + merge_metadata(metadata, Value::Object(correlation)) +} + +// Adds correlation metadata to tool spans created from hook events. Consumed hints preserve the +// provider-side tool id/name and extracted arguments so ambiguous or fallback ownership can be +// debugged from emitted events. +pub(super) fn tool_correlation_metadata( + metadata: Value, + status: &str, + source: Option<&str>, + subagent_id: Option<&str>, + hint: Option<&ToolHint>, +) -> Value { + let mut correlation = Map::new(); + correlation.insert("tool_correlation_status".into(), json!(status)); + if let Some(source) = source { + correlation.insert("tool_correlation_source".into(), json!(source)); + } + if let Some(subagent_id) = subagent_id { + correlation.insert("tool_correlation_subagent_id".into(), json!(subagent_id)); + } + if let Some(hint) = hint { + insert_optional( + &mut correlation, + "tool_correlation_tool_call_id", + hint.tool_call_id.as_deref(), + ); + insert_optional( + &mut correlation, + "tool_correlation_tool_name", + hint.tool_name.as_deref(), + ); + if !hint.arguments.is_null() { + correlation.insert("tool_correlation_arguments".into(), hint.arguments.clone()); + } + } + merge_metadata(metadata, Value::Object(correlation)) +} + +// Extracts the source agent kind from any normalized event variant so newly created sessions can +// inherit the correct agent identity before an explicit agent-start hook arrives. +pub(super) fn event_agent_kind(event: &NormalizedEvent) -> AgentKind { + match event { + NormalizedEvent::AgentStarted(event) + | NormalizedEvent::AgentEnded(event) + | NormalizedEvent::TurnEnded(event) + | NormalizedEvent::PromptSubmitted(event) + | NormalizedEvent::Compaction(event) + | NormalizedEvent::Notification(event) + | NormalizedEvent::HookMark(event) => event.agent_kind, + NormalizedEvent::LlmHint(event) => event.agent_kind, + NormalizedEvent::SubagentStarted(event) | NormalizedEvent::SubagentEnded(event) => { + event.agent_kind + } + NormalizedEvent::LlmStarted(event) | NormalizedEvent::LlmEnded(event) => event.agent_kind, + NormalizedEvent::ToolStarted(event) | NormalizedEvent::ToolEnded(event) => event.agent_kind, + } +} + +// Returns a session id only when exactly one session is active. Gateway requests without explicit +// session headers use this narrow fallback to avoid cross-correlating concurrent agents. +pub(super) fn single_active_session_id(sessions: &HashMap) -> Option { + let now = std::time::Instant::now(); + let mut active = sessions + .iter() + .filter(|(_, session)| session.is_active_or_recent(now)); + let (session_id, _) = active.next()?; + active.next().is_none().then(|| session_id.clone()) +} + +// Selects a gateway session without guessing between concurrent agents. An explicit session id or +// the sole active session is safe to retain. With no sessions, the stable synthetic root preserves +// pure-proxy continuity. When multiple sessions are active and the request carries no join key, +// isolate that request in a unique short-lived root instead of cross-correlating unrelated agents. +pub(super) fn gateway_session_for_call( + start: &LlmGatewayStart, + sessions: &HashMap, +) -> (String, GatewaySessionFinish) { + if let Some(session_id) = start.session_id.clone() { + return (session_id, GatewaySessionFinish::Retain); + } + if let Some(session_id) = single_active_session_id(sessions) { + return (session_id, GatewaySessionFinish::Retain); + } + if sessions.is_empty() { + return ( + format!("{}-gateway", AgentKind::Gateway.as_str()), + GatewaySessionFinish::Retain, + ); + } + ( + format!("gateway-isolated-{}", uuid::Uuid::now_v7()), + GatewaySessionFinish::Close, + ) +} diff --git a/crates/cli/src/sessions/idle.rs b/crates/cli/src/sessions/idle.rs new file mode 100644 index 000000000..fc96b8ae1 --- /dev/null +++ b/crates/cli/src/sessions/idle.rs @@ -0,0 +1,110 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Idle-session sweeping and shutdown closure. + +use std::collections::{HashMap, HashSet, hash_map::Entry}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use nemo_relay::api::runtime::TASK_SCOPE_STACK; +use tokio::sync::Mutex; + +use crate::agents::shared::alignment::SessionAlignmentState; +use crate::error::CliError; + +use super::Session; + +pub(super) const AGENT_IDLE_TIMEOUT: Duration = Duration::from_secs(30); +pub(super) const AGENT_IDLE_SWEEP_INTERVAL: Duration = Duration::from_secs(5); + +pub(super) async fn close_sessions_for_shutdown( + sessions: &mut [Session], + reason: &str, +) -> Result<(), CliError> { + let mut first_error = None; + for session in sessions { + if let Err(error) = session.close_for_shutdown(reason).await + && first_error.is_none() + { + first_error = Some(error); + } + } + first_error.map_or(Ok(()), Err) +} + +pub(super) async fn close_idle_sessions_from_parts( + inner: &Arc>>, + alignment: &Arc>, + now: Instant, + timeout: Duration, + reason: &str, +) -> Result { + let mut idle_sessions = Vec::new(); + { + let mut sessions = inner.lock().await; + let ids = sessions + .iter() + .filter_map(|(session_id, session)| { + session + .is_idle_for(now, timeout) + .then_some(session_id.clone()) + }) + .collect::>(); + for session_id in ids { + if let Some(session) = sessions.remove(&session_id) { + idle_sessions.push((session_id, session)); + } + } + } + if idle_sessions.is_empty() { + return Ok(0); + } + let mut closed_turns = 0; + let mut closed_subagents = Vec::new(); + let mut retained_sessions = Vec::new(); + let mut first_error = None; + for (session_id, mut session) in idle_sessions { + let stack = session.scope_stack.clone(); + let result = TASK_SCOPE_STACK + .scope(stack, async { session.close_turn_for_reason(reason).await }) + .await; + match result { + Ok(subagent_ids) => { + closed_turns += 1; + for subagent_id in subagent_ids { + closed_subagents.push((session_id.clone(), subagent_id)); + } + } + Err(error) if first_error.is_none() => first_error = Some(error), + Err(_) => {} + } + if !session.is_empty() { + retained_sessions.push((session_id, session)); + } + } + let mut alignment_cleanup_sessions = HashSet::new(); + { + let mut sessions = inner.lock().await; + for (session_id, session) in retained_sessions { + if let Entry::Vacant(entry) = sessions.entry(session_id.clone()) { + entry.insert(session); + alignment_cleanup_sessions.insert(session_id); + } + } + for (session_id, _) in &closed_subagents { + if !sessions.contains_key(session_id) { + alignment_cleanup_sessions.insert(session_id.clone()); + } + } + } + if !closed_subagents.is_empty() { + let mut alignment_state = alignment.lock().await; + for (session_id, subagent_id) in closed_subagents { + if alignment_cleanup_sessions.contains(&session_id) { + alignment_state.clear_for_ended_subagent(&session_id, &subagent_id); + } + } + } + first_error.map_or(Ok(closed_turns), Err) +} diff --git a/crates/cli/src/session.rs b/crates/cli/src/sessions/mod.rs similarity index 77% rename from crates/cli/src/session.rs rename to crates/cli/src/sessions/mod.rs index 130a72f73..43d66aa0c 100644 --- a/crates/cli/src/session.rs +++ b/crates/cli/src/sessions/mod.rs @@ -23,22 +23,30 @@ use nemo_relay::api::tool::{ use serde_json::{Map, Value, json}; use tokio::sync::Mutex; -use crate::adapters::{SKILL_LOAD_SOURCE_KEY, SKILL_LOAD_SOURCE_PROMPT_EXPANSION}; -use crate::alignment::{ - self, GatewayManagementPolicy, PendingSubagentStart, SessionAlias, SessionAlignmentState, - insert_optional, json_string_at, json_value_at, merge_metadata, +use crate::agents::shared::adapters::{SKILL_LOAD_SOURCE_KEY, SKILL_LOAD_SOURCE_PROMPT_EXPANSION}; +use crate::agents::shared::alignment::{ + self, GatewayManagementPolicy, SessionAlias, SessionAlignmentState, insert_optional, + json_string_at, json_value_at, merge_metadata, }; -use crate::config::{GatewayConfig, SessionConfig}; +use crate::configuration::{GatewayConfig, SessionConfig}; use crate::error::CliError; -use crate::model::{ +mod correlation; +mod idle; +mod routing; +mod types; + +use correlation::*; +use idle::*; +use routing::*; +pub(crate) use types::*; + +use crate::events::{ AgentKind, LlmEvent, LlmHintEvent, NormalizedEvent, SessionEvent, SubagentEvent, ToolEvent, }; const LLM_HINT_TTL: Duration = Duration::from_secs(300); const TOOL_HINT_TTL: Duration = Duration::from_secs(300); const LAST_OWNER_TTL: Duration = Duration::from_secs(300); -const AGENT_IDLE_TIMEOUT: Duration = Duration::from_secs(30); -const AGENT_IDLE_SWEEP_INTERVAL: Duration = Duration::from_secs(5); const ROUTING_IDENTITY_HEADERS: &[&str] = &[ "x-nemo-relay-session-id", "x-nemo-relay-agent-kind", @@ -62,53 +70,6 @@ pub(crate) struct SessionManager { default_config: GatewayConfig, } -#[derive(Debug, Clone)] -pub(crate) struct LlmGatewayStart { - pub(crate) session_id: Option, - pub(crate) provider: String, - pub(crate) model_name: Option, - pub(crate) subagent_id: Option, - pub(crate) conversation_id: Option, - pub(crate) generation_id: Option, - pub(crate) request_id: Option, - pub(crate) request: LlmRequest, - pub(crate) streaming: bool, - pub(crate) metadata: Value, -} - -/// Legacy active-LLM record kept for tests that exercise the manual `llm_call` / -/// `llm_call_end` correlation path. Production gateway traffic now uses managed execution via -/// [`SessionManager::prepare_gateway_call`]. -#[cfg(test)] -#[derive(Debug, Clone)] -pub(crate) struct ActiveLlm { - stack: ScopeStackHandle, - handle: LlmHandle, - session_id: String, - owner_subagent_id: Option, -} - -/// Inputs prepared by [`SessionManager::prepare_gateway_call`] for invoking the -/// runtime's managed LLM execution pipeline outside the session lock. -/// -/// The session lock is released after the prep is built, so the gateway can run -/// the upstream HTTP work without blocking unrelated session activity. The -/// preserved `scope_stack` is what restores the turn/subagent scope context -/// the call was opened against when the runtime emits start/end events. -pub(crate) struct GatewayCallPrep { - pub(crate) scope_stack: ScopeStackHandle, - pub(crate) session_id: String, - pub(crate) provider_name: String, - pub(crate) request: LlmRequest, - pub(crate) parent: Option, - pub(crate) attributes: LlmAttributes, - pub(crate) metadata: Value, - pub(crate) model_name: Option, - pub(crate) owner_subagent_id: Option, - pub(crate) bypass_managed_pipeline: bool, - pub(crate) prune_empty_session_on_finish: bool, -} - struct RoutingIdentityHeaderContext<'a> { session_id: &'a str, agent_kind: AgentKind, @@ -189,7 +150,7 @@ fn insert_routing_identity_header(headers: &mut Map, name: &str, headers.insert(name.to_string(), json!(value)); } -struct Session { +pub(super) struct Session { agent_kind: AgentKind, session_id: String, scope_stack: ScopeStackHandle, @@ -481,11 +442,7 @@ impl SessionManager { let config = self.default_config.session_config_from_headers(headers); self.resolve_start_alias(&mut start, config.clone()).await?; let mut sessions = self.inner.lock().await; - let session_id = start - .session_id - .clone() - .or_else(|| single_active_session_id(&sessions)) - .unwrap_or_else(|| format!("{}-gateway", AgentKind::Gateway.as_str())); + let (session_id, session_finish) = gateway_session_for_call(&start, &sessions); // Match `start_llm`: when this path creates a brand-new session (real agent's gateway // request beats its SessionStart hook), label the session by the provider so ATIF and // Phoenix scopes carry the agent identity instead of freezing on "gateway". @@ -497,10 +454,15 @@ impl SessionManager { let result = session.prepare_gateway_call(start).await; match result { Ok(mut prep) => { - prep.prune_empty_session_on_finish = prep.bypass_managed_pipeline + prep.session_finish = if prep.bypass_managed_pipeline && sessions .get(&session_id) - .is_some_and(|session| session.is_empty()); + .is_some_and(|session| session.is_empty()) + { + GatewaySessionFinish::PruneIfEmpty + } else { + session_finish + }; Ok(prep) } Err(error) => { @@ -521,25 +483,41 @@ impl SessionManager { /// Runtime-managed LLM spans are emitted outside the session lock, so the session keeps a small /// in-flight counter to prevent the idle sweeper from closing a turn while an upstream /// provider request or streaming response is still active. - pub(crate) async fn finish_gateway_call(&self, session_id: &str, prune_empty_session: bool) { + pub(crate) async fn finish_gateway_call(&self, session_id: &str, finish: GatewaySessionFinish) { let mut sessions = self.inner.lock().await; if let Some(session) = sessions.get_mut(session_id) { session.finish_gateway_call(); } - if prune_empty_session - && sessions - .get(session_id) - .is_some_and(|session| session.is_empty() && session.active_gateway_calls == 0) + let completed = sessions.get(session_id).is_some_and(|session| { + session.active_gateway_calls == 0 + && match finish { + GatewaySessionFinish::Retain => false, + GatewaySessionFinish::PruneIfEmpty => session.is_empty(), + GatewaySessionFinish::Close => true, + } + }); + let mut closing = completed.then(|| sessions.remove(session_id)).flatten(); + drop(sessions); + + if finish == GatewaySessionFinish::Close + && let Some(session) = closing.as_mut() + && let Err(error) = session + .close_for_shutdown("uncorrelated_gateway_call_complete") + .await { - sessions.remove(session_id); + eprintln!( + "nemo-relay CLI gateway: failed to close isolated session {session_id}: {error}" + ); } } /// Returns true while any session still owns active observable work. /// - /// Codex plugin sessions can emit `SessionStart` without a matching `SessionEnd`, so metadata-only - /// sessions must not keep the hook-supervised sidecar alive forever. Open scopes and in-flight - /// tool, LLM, or gateway work still block plugin idle shutdown. + /// Host sessions can remain durable after their current turn ends: Codex may omit `SessionEnd`, + /// while Hermes keeps a session open for later resumption. A dormant agent scope must therefore + /// not keep the MCP-managed sidecar alive forever. Active turns, subagents, tools, LLMs, and + /// gateway calls still block idle shutdown; [`Self::close_all`] balances the dormant agent scope + /// when the gateway exits. pub(crate) async fn has_open_sessions(&self) -> bool { self.inner .lock() @@ -684,261 +662,6 @@ impl SessionManager { } } -// Mutates a gateway LLM start in place after alias resolution. The parent session id is what the -// runtime session manager should open, while the subagent id and alias metadata preserve the child -// thread as the LLM owner. -fn apply_start_alias(start: &mut LlmGatewayStart, alias: &SessionAlias) { - start.session_id = Some(alias.parent_session_id.clone()); - start.subagent_id = Some(alias.subagent_id.clone()); - start.metadata = merge_metadata(start.metadata.clone(), alias.metadata()); -} - -// Handles child SessionStart events before normal per-session dispatch. Some harnesses advertise a -// parent session on SessionStart; when the child is still empty, queue or promote that start as a -// subagent instead of letting it open a new root trace. Applies to Codex child threads today. -async fn queue_or_promote_child_start( - event: &mut NormalizedEvent, - sessions: &mut HashMap, - alignment_state: &mut SessionAlignmentState, - config: SessionConfig, -) -> Result { - let Some((child_session_id, pending)) = alignment::pending_subagent_start(event).await else { - return Ok(false); - }; - if sessions - .get(&child_session_id) - .is_some_and(|session| !session.can_reparent_as_subagent_alias()) - { - return Ok(false); - } - if sessions.contains_key(pending.parent_session_id()) { - alignment_state.remove_pending(&child_session_id); - promote_pending_subagent(sessions, alignment_state, child_session_id, pending, config) - .await?; - } else { - // Child-first ordering is possible for harness-managed children. Drop any empty child - // placeholder and wait until the parent hook or a gateway LLM forces promotion. Applies to - // Codex transparent runs today. - sessions.remove(&child_session_id); - alignment_state.insert_pending(child_session_id, pending); - } - Ok(true) -} - -async fn apply_event_to_session( - sessions: &mut HashMap, - session_id: &str, - event: NormalizedEvent, - event_kind: AgentKind, - config: SessionConfig, - is_agent_started: bool, -) -> Result { - let session = sessions - .entry(session_id.to_string()) - .or_insert_with(|| Session::new(session_id.to_string(), event_kind, config)); - if is_agent_started - && session.agent_kind == AgentKind::Gateway - && event_kind != AgentKind::Gateway - { - session.agent_kind = event_kind; - } - session.apply(event).await?; - Ok(session.is_empty()) -} - -// Promotes all child SessionStart hooks that were waiting on a newly opened parent. Multiple -// children can wait for the same parent when parallel harness-managed subagents start before the -// root hook is observed. Applies to Codex child threads today. -async fn promote_pending_subagents_for_parent( - sessions: &mut HashMap, - alignment_state: &mut SessionAlignmentState, - parent_session_id: &str, - config: SessionConfig, -) -> Result<(), CliError> { - for (child_session_id, pending) in alignment_state.pending_for_parent(parent_session_id) { - promote_pending_subagent( - sessions, - alignment_state, - child_session_id, - pending, - config.clone(), - ) - .await?; - } - Ok(()) -} - -// Converts one pending child SessionStart into a parent-owned subagent and installs the alias used -// by later child-session events. If the child session gained real activity while pending, promotion -// is skipped rather than moving existing LLM/tool handles across scopes. Applies to Codex child -// threads today. -async fn promote_pending_subagent( - sessions: &mut HashMap, - alignment_state: &mut SessionAlignmentState, - child_session_id: String, - pending: PendingSubagentStart, - config: SessionConfig, -) -> Result, CliError> { - if sessions - .get(&child_session_id) - .is_some_and(|session| !session.can_reparent_as_subagent_alias()) - { - return Ok(None); - } - sessions.remove(&child_session_id); - let parent_session_id = pending.parent_session_id().to_string(); - let parent_session = sessions - .entry(parent_session_id.clone()) - .or_insert_with(|| { - Session::new(parent_session_id.clone(), pending.event.agent_kind, config) - }); - if !parent_session.session_started && parent_session.agent_scope.is_none() { - // Gateway traffic can be the first signal that forces promotion. In that case, synthesize - // the parent session metadata; the later subagent start will create a turn-scoped parent. - parent_session - .apply(NormalizedEvent::AgentStarted(SessionEvent { - session_id: parent_session_id, - agent_kind: pending.event.agent_kind, - event_name: "implicit_parent_for_aligned_subagent".into(), - payload: Value::Null, - metadata: Value::Null, - })) - .await?; - } - let subagent_event = pending.subagent_start_event(); - parent_session - .apply(NormalizedEvent::SubagentStarted(subagent_event)) - .await?; - let alias = pending.alias_for_child_session(child_session_id.clone()); - alignment_state.insert_alias(child_session_id, alias.clone()); - Ok(Some(alias)) -} - -async fn close_sessions_for_shutdown( - sessions: &mut [Session], - reason: &str, -) -> Result<(), CliError> { - let mut first_error = None; - for session in sessions { - if let Err(error) = session.close_for_shutdown(reason).await - && first_error.is_none() - { - first_error = Some(error); - } - } - first_error.map_or(Ok(()), Err) -} - -async fn close_idle_sessions_from_parts( - inner: &Arc>>, - alignment: &Arc>, - now: Instant, - timeout: Duration, - reason: &str, -) -> Result { - let mut idle_sessions = Vec::new(); - { - let mut sessions = inner.lock().await; - let ids = sessions - .iter() - .filter_map(|(session_id, session)| { - session - .is_idle_for(now, timeout) - .then_some(session_id.clone()) - }) - .collect::>(); - for session_id in ids { - if let Some(session) = sessions.remove(&session_id) { - idle_sessions.push((session_id, session)); - } - } - } - if idle_sessions.is_empty() { - return Ok(0); - } - let mut closed_turns = 0; - let mut closed_subagents = Vec::new(); - let mut retained_sessions = Vec::new(); - let mut first_error = None; - for (session_id, mut session) in idle_sessions { - let stack = session.scope_stack.clone(); - let result = TASK_SCOPE_STACK - .scope(stack, async { session.close_turn_for_reason(reason).await }) - .await; - match result { - Ok(subagent_ids) => { - closed_turns += 1; - for subagent_id in subagent_ids { - closed_subagents.push((session_id.clone(), subagent_id)); - } - } - Err(error) if first_error.is_none() => first_error = Some(error), - Err(_) => {} - } - if !session.is_empty() { - retained_sessions.push((session_id, session)); - } - } - { - let mut sessions = inner.lock().await; - sessions.extend(retained_sessions); - } - if !closed_subagents.is_empty() { - let mut alignment_state = alignment.lock().await; - for (session_id, subagent_id) in closed_subagents { - alignment_state.clear_for_ended_subagent(&session_id, &subagent_id); - } - } - first_error.map_or(Ok(closed_turns), Err) -} - -fn route_event_for_session( - event: NormalizedEvent, - sessions: &mut HashMap, - alignment_state: &mut SessionAlignmentState, -) -> Option<(NormalizedEvent, String, bool)> { - let mut event = alignment_state.route_event(event); - let explicit_subagent_alias = alignment::explicit_subagent_alias(&mut event); - let session_id = event.session_id().to_string(); - let is_agent_started = matches!(&event, NormalizedEvent::AgentStarted(_)); - - if event.is_terminal() && !sessions.contains_key(&session_id) { - return None; - } - if !apply_explicit_subagent_alias( - &mut event, - sessions, - alignment_state, - explicit_subagent_alias, - ) { - return None; - } - Some((event, session_id, is_agent_started)) -} - -fn apply_explicit_subagent_alias( - event: &mut NormalizedEvent, - sessions: &mut HashMap, - alignment_state: &mut SessionAlignmentState, - explicit_subagent_alias: Option<(String, SessionAlias)>, -) -> bool { - let Some((child_session_id, alias)) = explicit_subagent_alias else { - alignment_state.align_explicit_subagent_end(event); - return true; - }; - - if sessions - .get(&child_session_id) - .is_some_and(|session| !session.can_reparent_as_subagent_alias()) - { - return false; - } - sessions.remove(&child_session_id); - alignment_state.insert_alias(child_session_id, alias); - alignment_state.align_explicit_subagent_end(event); - true -} - impl Session { // Constructs per-session runtime state without creating a scope yet. The root agent scope is // opened lazily on the first event or gateway LLM call so sessions created from hints and pure @@ -990,8 +713,7 @@ impl Session { } fn blocks_plugin_idle_shutdown(&self) -> bool { - self.agent_scope.is_some() - || self.turn_scope.is_some() + self.turn_scope.is_some() || !self.subagents.is_empty() || !self.subagent_stacks.is_empty() || !self.subagent_stack.is_empty() @@ -1022,6 +744,13 @@ impl Session { && now.duration_since(self.last_activity) >= timeout } + fn is_active_or_recent(&self, now: Instant) -> bool { + self.blocks_plugin_idle_shutdown() + || now + .checked_duration_since(self.last_activity) + .is_none_or(|elapsed| elapsed < AGENT_IDLE_TIMEOUT) + } + // Runs one normalized hook event inside this session's scope stack. Dispatch stays synchronous // inside the scoped closure so lifecycle ordering from each hook request is preserved exactly. async fn apply(&mut self, event: NormalizedEvent) -> Result<(), CliError> { @@ -1180,7 +909,7 @@ impl Session { model_name: start.model_name, owner_subagent_id: owner.subagent_id, bypass_managed_pipeline: policy.bypasses_managed_pipeline(), - prune_empty_session_on_finish: false, + session_finish: GatewaySessionFinish::Retain, }) }) .await; @@ -1357,15 +1086,22 @@ impl Session { .await?; return Ok(()); } - self.close_turn(event.payload, "closed_by_turn_end").await?; + self.close_turn(event.payload, Some(event.metadata), "closed_by_turn_end") + .await?; Ok(()) } async fn close_turn_for_reason(&mut self, reason: &str) -> Result, CliError> { - self.close_turn(json!({ "status": reason }), reason).await + self.close_turn(json!({ "status": reason }), None, reason) + .await } - async fn close_turn(&mut self, output: Value, reason: &str) -> Result, CliError> { + async fn close_turn( + &mut self, + output: Value, + boundary_metadata: Option, + reason: &str, + ) -> Result, CliError> { if self.turn_scope.is_none() { return Ok(Vec::new()); } @@ -1374,7 +1110,7 @@ impl Session { let closed_subagents = self.close_active_subagents(reason).await?; let output = self.last_turn_llm_output.take().unwrap_or(output); self.clear_correlation_state(); - self.close_turn_scope(output)?; + self.close_turn_scope(output, boundary_metadata)?; Ok(closed_subagents) } @@ -1481,7 +1217,11 @@ impl Session { Ok(()) } - fn close_turn_scope(&mut self, output: Value) -> Result<(), CliError> { + fn close_turn_scope( + &mut self, + output: Value, + boundary_metadata: Option, + ) -> Result<(), CliError> { let Some(scope) = self.turn_scope.take() else { return Ok(()); }; @@ -1490,6 +1230,7 @@ impl Session { PopScopeParams::builder() .handle_uuid(&scope.uuid) .output(output) + .metadata_opt(boundary_metadata) .build(), )?; Ok(()) @@ -2340,326 +2081,6 @@ impl Session { // Scores how strongly a pending hint matches a gateway LLM request. Subagent/agent identity is // weighted highest, request/conversation/generation identifiers are equal, and model match is only // a low-confidence tie breaker. -fn hint_match_score(hint: &LlmHintEvent, start: &LlmGatewayStart) -> u8 { - let mut score = 0; - if same_optional(hint.subagent_id.as_deref(), start.subagent_id.as_deref()) - || same_optional(hint.agent_id.as_deref(), start.subagent_id.as_deref()) - { - score += 8; - } - if same_optional( - hint.conversation_id.as_deref(), - start.conversation_id.as_deref(), - ) { - score += 4; - } - if same_optional( - hint.generation_id.as_deref(), - start.generation_id.as_deref(), - ) { - score += 4; - } - if same_optional(hint.request_id.as_deref(), start.request_id.as_deref()) { - score += 4; - } - if same_optional(hint.model.as_deref(), start.model_name.as_deref()) { - score += 1; - } - score -} - -// Extracts tool-call hints from common provider response shapes. These private hints let later -// hook-only tool events attach to the subagent that received the LLM response proposing the tool. -fn tool_hints_from_llm_response( - response: &Value, - owner_subagent_id: Option, -) -> Vec { - let mut hints = Vec::new(); - collect_openai_chat_tool_hints(response, owner_subagent_id.as_deref(), &mut hints); - collect_openai_response_tool_hints(response, owner_subagent_id.as_deref(), &mut hints); - collect_anthropic_tool_hints(response, owner_subagent_id.as_deref(), &mut hints); - hints -} - -// Collects OpenAI Chat Completions `choices[].message.tool_calls[]` entries and preserves -// stringified function arguments as parsed JSON when possible. -fn collect_openai_chat_tool_hints( - response: &Value, - owner_subagent_id: Option<&str>, - hints: &mut Vec, -) { - let Some(choices) = response.get("choices").and_then(Value::as_array) else { - return; - }; - for choice in choices { - let Some(tool_calls) = choice - .get("message") - .and_then(|message| message.get("tool_calls")) - .and_then(Value::as_array) - else { - continue; - }; - for call in tool_calls { - push_tool_hint( - hints, - call, - owner_subagent_id, - "openai_chat_tool_call", - &[&["id"][..], &["call_id"][..]], - &[&["function", "name"][..], &["name"][..]], - &[&["function", "arguments"][..], &["arguments"][..]], - ); - } - } -} - -// Collects OpenAI Responses output items where function-call data is usually direct on each item. -// Items without an id or name are ignored because they are too weak for ownership correlation. -fn collect_openai_response_tool_hints( - response: &Value, - owner_subagent_id: Option<&str>, - hints: &mut Vec, -) { - let Some(output) = response.get("output").and_then(Value::as_array) else { - return; - }; - for item in output { - if item.get("type").and_then(Value::as_str) != Some("function_call") { - continue; - } - push_tool_hint( - hints, - item, - owner_subagent_id, - "openai_response_tool_call", - &[&["call_id"][..], &["id"][..]], - &[&["name"][..], &["tool_name"][..]], - &[&["arguments"][..], &["input"][..]], - ); - } -} - -// Collects Anthropic `tool_use` blocks from top-level or nested message content arrays. Other -// content block types are skipped so text and thinking blocks never become tool hints. -fn collect_anthropic_tool_hints( - response: &Value, - owner_subagent_id: Option<&str>, - hints: &mut Vec, -) { - for content in [ - response.get("content"), - response - .get("message") - .and_then(|message| message.get("content")), - ] - .into_iter() - .flatten() - .filter_map(Value::as_array) - { - for block in content { - if json_string_at(block, &[&["type"][..]]).as_deref() == Some("tool_use") { - push_tool_hint( - hints, - block, - owner_subagent_id, - "anthropic_tool_use", - &[&["id"][..], &["tool_use_id"][..]], - &[&["name"][..], &["tool_name"][..]], - &[&["input"][..], &["arguments"][..]], - ); - } - } - } -} - -// Appends one provider tool hint when an object carries either a tool-call id or enough -// name-plus-argument data to disambiguate common tool names. Name-only and argument-only hints are -// skipped because they over-match across unrelated tools in parallel coding-agent sessions. -fn push_tool_hint( - hints: &mut Vec, - object: &Value, - owner_subagent_id: Option<&str>, - source: &str, - id_paths: &[&[&str]], - name_paths: &[&[&str]], - argument_paths: &[&[&str]], -) { - let tool_call_id = json_string_at(object, id_paths); - let tool_name = json_string_at(object, name_paths); - let arguments = json_value_at(object, argument_paths) - .map(normalize_tool_arguments) - .unwrap_or(Value::Null); - if tool_call_id.is_none() && (tool_name.is_none() || arguments.is_null()) { - return; - } - hints.push(ToolHint { - tool_call_id, - tool_name, - subagent_id: owner_subagent_id.map(ToOwned::to_owned), - arguments, - source: source.to_string(), - }); -} - -// Scores how strongly a pending provider tool hint matches an observed hook event. A shared -// provider call id is strongest. Without an id match, require both tool name and exact arguments so -// repeated coding-agent tool names cannot claim unrelated hooks. -fn tool_hint_match_score(hint: &ToolHint, event: &ToolEvent) -> u8 { - let mut score = 0; - let id_matches = same_optional( - hint.tool_call_id.as_deref(), - Some(event.tool_call_id.as_str()), - ); - let name_matches = same_optional(hint.tool_name.as_deref(), Some(event.tool_name.as_str())); - let arguments_match = !hint.arguments.is_null() - && !event.arguments.is_null() - && hint.arguments == event.arguments; - if id_matches { - score += 12; - } - if id_matches && name_matches { - score += 4; - } - if id_matches && arguments_match { - score += 1; - } - if !id_matches && name_matches && arguments_match { - score += 5; - } - score -} - -fn same_optional(left: Option<&str>, right: Option<&str>) -> bool { - matches!((left, right), (Some(left), Some(right)) if left == right) -} - -fn owner_status_teaches_request_affinity(status: &str) -> bool { - matches!( - status, - "explicit" - | "single_hint" - | "matched_hint" - | "active_subagent" - | "subagent_start" - | "request_affinity" - ) -} - -// Parses stringified tool arguments when providers encode them as JSON text. Non-JSON strings are -// preserved as strings so metadata still reflects what the provider actually returned. -fn normalize_tool_arguments(arguments: Value) -> Value { - match arguments { - Value::String(raw) => serde_json::from_str(&raw).unwrap_or(Value::String(raw)), - value => value, - } -} - -// Adds correlation status and consumed-hint identifiers to the LLM event metadata. Caller metadata -// is merged first so correlation keys win when names collide. -fn llm_correlation_metadata( - metadata: Value, - status: &str, - source: Option<&str>, - subagent_id: Option<&str>, - hint: Option<&LlmHintEvent>, -) -> Value { - let mut correlation = Map::new(); - correlation.insert("llm_correlation_status".into(), json!(status)); - if let Some(source) = source { - correlation.insert("llm_correlation_source".into(), json!(source)); - } - if let Some(subagent_id) = subagent_id { - correlation.insert("llm_correlation_subagent_id".into(), json!(subagent_id)); - } - if let Some(hint) = hint { - insert_optional( - &mut correlation, - "llm_correlation_conversation_id", - hint.conversation_id.as_deref(), - ); - insert_optional( - &mut correlation, - "llm_correlation_generation_id", - hint.generation_id.as_deref(), - ); - insert_optional( - &mut correlation, - "llm_correlation_request_id", - hint.request_id.as_deref(), - ); - insert_optional( - &mut correlation, - "llm_correlation_agent_type", - hint.agent_type.as_deref(), - ); - } - merge_metadata(metadata, Value::Object(correlation)) -} - -// Adds correlation metadata to tool spans created from hook events. Consumed hints preserve the -// provider-side tool id/name and extracted arguments so ambiguous or fallback ownership can be -// debugged from emitted events. -fn tool_correlation_metadata( - metadata: Value, - status: &str, - source: Option<&str>, - subagent_id: Option<&str>, - hint: Option<&ToolHint>, -) -> Value { - let mut correlation = Map::new(); - correlation.insert("tool_correlation_status".into(), json!(status)); - if let Some(source) = source { - correlation.insert("tool_correlation_source".into(), json!(source)); - } - if let Some(subagent_id) = subagent_id { - correlation.insert("tool_correlation_subagent_id".into(), json!(subagent_id)); - } - if let Some(hint) = hint { - insert_optional( - &mut correlation, - "tool_correlation_tool_call_id", - hint.tool_call_id.as_deref(), - ); - insert_optional( - &mut correlation, - "tool_correlation_tool_name", - hint.tool_name.as_deref(), - ); - if !hint.arguments.is_null() { - correlation.insert("tool_correlation_arguments".into(), hint.arguments.clone()); - } - } - merge_metadata(metadata, Value::Object(correlation)) -} - -// Extracts the source agent kind from any normalized event variant so newly created sessions can -// inherit the correct agent identity before an explicit agent-start hook arrives. -fn event_agent_kind(event: &NormalizedEvent) -> AgentKind { - match event { - NormalizedEvent::AgentStarted(event) - | NormalizedEvent::AgentEnded(event) - | NormalizedEvent::TurnEnded(event) - | NormalizedEvent::PromptSubmitted(event) - | NormalizedEvent::Compaction(event) - | NormalizedEvent::Notification(event) - | NormalizedEvent::HookMark(event) => event.agent_kind, - NormalizedEvent::LlmHint(event) => event.agent_kind, - NormalizedEvent::SubagentStarted(event) | NormalizedEvent::SubagentEnded(event) => { - event.agent_kind - } - NormalizedEvent::LlmStarted(event) | NormalizedEvent::LlmEnded(event) => event.agent_kind, - NormalizedEvent::ToolStarted(event) | NormalizedEvent::ToolEnded(event) => event.agent_kind, - } -} - -// Returns a session id only when exactly one session is active. Gateway requests without explicit -// session headers use this narrow fallback to avoid cross-correlating concurrent agents. -fn single_active_session_id(sessions: &HashMap) -> Option { - (sessions.len() == 1) - .then(|| sessions.keys().next().cloned()) - .flatten() -} - #[cfg(test)] -#[path = "../tests/coverage/session_tests.rs"] +#[path = "../../tests/coverage/shared/session_tests.rs"] mod tests; diff --git a/crates/cli/src/sessions/routing.rs b/crates/cli/src/sessions/routing.rs new file mode 100644 index 000000000..44de606e0 --- /dev/null +++ b/crates/cli/src/sessions/routing.rs @@ -0,0 +1,176 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Child-session aliasing and lifecycle-event routing. + +use std::collections::HashMap; + +use serde_json::Value; + +use crate::agents::shared::alignment::{ + self, PendingSubagentStart, SessionAlias, SessionAlignmentState, merge_metadata, +}; +use crate::configuration::SessionConfig; +use crate::error::CliError; +use crate::events::{AgentKind, NormalizedEvent, SessionEvent}; + +use super::{LlmGatewayStart, Session}; + +pub(super) fn apply_start_alias(start: &mut LlmGatewayStart, alias: &SessionAlias) { + start.session_id = Some(alias.parent_session_id.clone()); + start.subagent_id = Some(alias.subagent_id.clone()); + start.metadata = merge_metadata(start.metadata.clone(), alias.metadata()); +} + +pub(super) async fn queue_or_promote_child_start( + event: &mut NormalizedEvent, + sessions: &mut HashMap, + alignment_state: &mut SessionAlignmentState, + config: SessionConfig, +) -> Result { + let Some((child_session_id, pending)) = alignment::pending_subagent_start(event).await else { + return Ok(false); + }; + if sessions + .get(&child_session_id) + .is_some_and(|session| !session.can_reparent_as_subagent_alias()) + { + return Ok(false); + } + if sessions.contains_key(pending.parent_session_id()) { + alignment_state.remove_pending(&child_session_id); + promote_pending_subagent(sessions, alignment_state, child_session_id, pending, config) + .await?; + } else { + sessions.remove(&child_session_id); + alignment_state.insert_pending(child_session_id, pending); + } + Ok(true) +} + +pub(super) async fn apply_event_to_session( + sessions: &mut HashMap, + session_id: &str, + event: NormalizedEvent, + event_kind: AgentKind, + config: SessionConfig, + is_agent_started: bool, +) -> Result { + let session = sessions + .entry(session_id.to_string()) + .or_insert_with(|| Session::new(session_id.to_string(), event_kind, config)); + if is_agent_started + && session.agent_kind == AgentKind::Gateway + && event_kind != AgentKind::Gateway + { + session.agent_kind = event_kind; + } + session.apply(event).await?; + Ok(session.is_empty()) +} + +pub(super) async fn promote_pending_subagents_for_parent( + sessions: &mut HashMap, + alignment_state: &mut SessionAlignmentState, + parent_session_id: &str, + config: SessionConfig, +) -> Result<(), CliError> { + for (child_session_id, pending) in alignment_state.pending_for_parent(parent_session_id) { + promote_pending_subagent( + sessions, + alignment_state, + child_session_id, + pending, + config.clone(), + ) + .await?; + } + Ok(()) +} + +pub(super) async fn promote_pending_subagent( + sessions: &mut HashMap, + alignment_state: &mut SessionAlignmentState, + child_session_id: String, + pending: PendingSubagentStart, + config: SessionConfig, +) -> Result, CliError> { + if sessions + .get(&child_session_id) + .is_some_and(|session| !session.can_reparent_as_subagent_alias()) + { + return Ok(None); + } + sessions.remove(&child_session_id); + let parent_session_id = pending.parent_session_id().to_string(); + let parent_session = sessions + .entry(parent_session_id.clone()) + .or_insert_with(|| { + Session::new(parent_session_id.clone(), pending.event.agent_kind, config) + }); + if !parent_session.session_started && parent_session.agent_scope.is_none() { + parent_session + .apply(NormalizedEvent::AgentStarted(SessionEvent { + session_id: parent_session_id, + agent_kind: pending.event.agent_kind, + event_name: "implicit_parent_for_aligned_subagent".into(), + payload: Value::Null, + metadata: Value::Null, + })) + .await?; + } + parent_session + .apply(NormalizedEvent::SubagentStarted( + pending.subagent_start_event(), + )) + .await?; + let alias = pending.alias_for_child_session(child_session_id.clone()); + alignment_state.insert_alias(child_session_id, alias.clone()); + Ok(Some(alias)) +} + +pub(super) fn route_event_for_session( + event: NormalizedEvent, + sessions: &mut HashMap, + alignment_state: &mut SessionAlignmentState, +) -> Option<(NormalizedEvent, String, bool)> { + let mut event = alignment_state.route_event(event); + let explicit_subagent_alias = alignment::explicit_subagent_alias(&mut event); + let session_id = event.session_id().to_string(); + let is_agent_started = matches!(&event, NormalizedEvent::AgentStarted(_)); + + if event.is_terminal() && !sessions.contains_key(&session_id) { + return None; + } + if !apply_explicit_subagent_alias( + &mut event, + sessions, + alignment_state, + explicit_subagent_alias, + ) { + return None; + } + Some((event, session_id, is_agent_started)) +} + +fn apply_explicit_subagent_alias( + event: &mut NormalizedEvent, + sessions: &mut HashMap, + alignment_state: &mut SessionAlignmentState, + explicit_subagent_alias: Option<(String, SessionAlias)>, +) -> bool { + let Some((child_session_id, alias)) = explicit_subagent_alias else { + alignment_state.align_explicit_subagent_end(event); + return true; + }; + if sessions + .get(&child_session_id) + .is_some_and(|session| !session.can_reparent_as_subagent_alias()) + { + return false; + } + sessions.remove(&child_session_id); + alignment_state.insert_alias(child_session_id, alias); + alignment_state.align_explicit_subagent_end(event); + true +} diff --git a/crates/cli/src/sessions/types.rs b/crates/cli/src/sessions/types.rs new file mode 100644 index 000000000..0e8cf84c6 --- /dev/null +++ b/crates/cli/src/sessions/types.rs @@ -0,0 +1,58 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Session gateway inputs and prepared-call outputs. + +#[cfg(test)] +use nemo_relay::api::llm::LlmHandle; +use nemo_relay::api::llm::{LlmAttributes, LlmRequest}; +use nemo_relay::api::runtime::ScopeStackHandle; +use nemo_relay::api::scope::ScopeHandle; +use serde_json::Value; + +#[derive(Debug, Clone)] +pub(crate) struct LlmGatewayStart { + pub(crate) session_id: Option, + pub(crate) provider: String, + pub(crate) model_name: Option, + pub(crate) subagent_id: Option, + pub(crate) conversation_id: Option, + pub(crate) generation_id: Option, + pub(crate) request_id: Option, + pub(crate) request: LlmRequest, + pub(crate) streaming: bool, + pub(crate) metadata: Value, +} + +/// Legacy active-LLM record retained for manual-correlation tests. +#[cfg(test)] +#[derive(Debug, Clone)] +pub(crate) struct ActiveLlm { + pub(super) stack: ScopeStackHandle, + pub(super) handle: LlmHandle, + pub(super) session_id: String, + pub(super) owner_subagent_id: Option, +} + +/// Inputs for invoking managed LLM execution after releasing the session lock. +pub(crate) struct GatewayCallPrep { + pub(crate) scope_stack: ScopeStackHandle, + pub(crate) session_id: String, + pub(crate) provider_name: String, + pub(crate) request: LlmRequest, + pub(crate) parent: Option, + pub(crate) attributes: LlmAttributes, + pub(crate) metadata: Value, + pub(crate) model_name: Option, + pub(crate) owner_subagent_id: Option, + pub(crate) bypass_managed_pipeline: bool, + pub(crate) session_finish: GatewaySessionFinish, +} + +/// Cleanup policy for the session selected by one gateway request. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum GatewaySessionFinish { + Retain, + PruneIfEmpty, + Close, +} diff --git a/crates/cli/tests/architecture_tests.rs b/crates/cli/tests/architecture_tests.rs new file mode 100644 index 000000000..bc4633127 --- /dev/null +++ b/crates/cli/tests/architecture_tests.rs @@ -0,0 +1,286 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Architectural dependency and source-layout regression tests. + +use std::fs; +use std::path::{Path, PathBuf}; + +use syn::visit::Visit; + +fn source_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("src") +} + +fn rust_files(root: &Path) -> Vec { + let mut files = Vec::new(); + let mut pending = vec![root.to_owned()]; + while let Some(path) = pending.pop() { + for entry in fs::read_dir(path).unwrap() { + let path = entry.unwrap().path(); + if path.is_dir() { + pending.push(path); + } else if path.extension().and_then(|value| value.to_str()) == Some("rs") { + files.push(path); + } + } + } + files +} + +fn syntax_paths(source: &str) -> Vec { + let file = syn::parse_file(source).expect("architecture fixture must parse as Rust"); + let mut visitor = PathVisitor::default(); + visitor.visit_file(&file); + for item in &file.items { + if let syn::Item::Use(item) = item { + expand_use_tree(Vec::new(), &item.tree, &mut visitor.paths); + } + } + visitor.paths +} + +#[derive(Default)] +struct PathVisitor { + paths: Vec, + command_attributes: Vec, +} + +impl<'ast> Visit<'ast> for PathVisitor { + fn visit_path(&mut self, path: &'ast syn::Path) { + self.paths.push( + path.segments + .iter() + .map(|segment| segment.ident.to_string()) + .collect::>() + .join("::"), + ); + syn::visit::visit_path(self, path); + } + + fn visit_attribute(&mut self, attribute: &'ast syn::Attribute) { + let name = attribute + .path() + .segments + .last() + .map(|segment| segment.ident.to_string()) + .unwrap_or_default(); + if matches!(name.as_str(), "arg" | "command" | "value") { + self.command_attributes.push(name); + } + syn::visit::visit_attribute(self, attribute); + } +} + +fn expand_use_tree(prefix: Vec, tree: &syn::UseTree, output: &mut Vec) { + match tree { + syn::UseTree::Path(path) => { + let mut prefix = prefix; + prefix.push(path.ident.to_string()); + expand_use_tree(prefix, &path.tree, output); + } + syn::UseTree::Name(name) => { + let mut path = prefix; + path.push(name.ident.to_string()); + output.push(path.join("::")); + } + syn::UseTree::Rename(rename) => { + let mut path = prefix; + path.push(rename.ident.to_string()); + output.push(path.join("::")); + } + syn::UseTree::Glob(_) => output.push(format!("{}::*", prefix.join("::"))), + syn::UseTree::Group(group) => { + for item in &group.items { + expand_use_tree(prefix.clone(), item, output); + } + } + } +} + +#[test] +fn syntax_analysis_expands_grouped_imports_and_ignores_comments() { + let paths = syntax_paths( + r#" + // use crate::commands::ignored; + use crate::{commands::install, agents::{codex, hermes as other}}; + "#, + ); + assert!(paths.contains(&"crate::commands::install".to_string())); + assert!(paths.contains(&"crate::agents::codex".to_string())); + assert!(paths.contains(&"crate::agents::hermes".to_string())); + assert!(!paths.iter().any(|path| path.contains("ignored"))); +} + +#[test] +fn retired_top_level_agent_modules_do_not_return() { + let src = source_root(); + for path in [ + "adapters", + "alignment", + "plugin_host", + "plugin_install", + "hermes.rs", + "coding_agent.rs", + "sidecar", + "sidecar.rs", + ] { + assert!(!src.join(path).exists(), "retired module returned: {path}"); + } +} + +#[test] +fn shared_services_do_not_depend_on_commands() { + let src = source_root(); + for path in rust_files(&src) { + if path.starts_with(src.join("commands")) || path == src.join("main.rs") { + continue; + } + let source = fs::read_to_string(&path).unwrap(); + let paths = syntax_paths(&source); + assert!( + !paths.iter().any(|path| path.starts_with("crate::commands")), + "shared module depends on command layer: {}", + path.display() + ); + } +} + +#[test] +fn clap_syntax_is_owned_exclusively_by_commands() { + let src = source_root(); + for path in rust_files(&src) { + if path.starts_with(src.join("commands")) { + continue; + } + let source = fs::read_to_string(&path).unwrap(); + let file = syn::parse_file(&source).unwrap(); + let mut visitor = PathVisitor::default(); + visitor.visit_file(&file); + assert!( + !visitor.paths.iter().any(|path| path.starts_with("clap")) + && visitor.command_attributes.is_empty(), + "{} contains command syntax", + path.display() + ); + } +} + +#[test] +fn tests_are_not_embedded_in_the_source_tree() { + let src = source_root(); + for path in rust_files(&src) { + let source = fs::read_to_string(&path).unwrap(); + assert!( + !source.contains("#[cfg(test)]\nmod tests {") + && !source.contains("#[cfg(test)]\r\nmod tests {"), + "inline test module found under src: {}", + path.display() + ); + } +} + +#[test] +fn agent_directories_do_not_import_one_another_or_commands() { + let agents = source_root().join("agents"); + for (agent, forbidden) in [ + ("codex", ["agents::claude", "agents::hermes"]), + ("claude", ["agents::codex", "agents::hermes"]), + ("hermes", ["agents::codex", "agents::claude"]), + ] { + for path in rust_files(&agents.join(agent)) { + let source = fs::read_to_string(&path).unwrap(); + let paths = syntax_paths(&source); + assert!( + !paths.iter().any(|path| path.starts_with("crate::commands")), + "{} imports commands", + path.display() + ); + for module in forbidden { + assert!( + !paths.iter().any(|path| path.contains(module)), + "{} imports {module}", + path.display() + ); + } + } + } +} + +#[test] +fn retired_horizontal_and_monolithic_modules_do_not_return() { + let src = source_root(); + for path in [ + "agents/install", + "agents/host.rs", + "agents/adapters.rs", + "agents/alignment.rs", + "commands/arguments.rs", + "configuration/setup.rs", + ] { + assert!(!src.join(path).exists(), "retired module returned: {path}"); + } +} + +#[test] +fn shared_installation_is_agent_neutral() { + let installation = source_root().join("installation"); + for path in rust_files(&installation) { + let source = fs::read_to_string(&path).unwrap(); + for marker in ["crate::agents", "CodingAgent", "IntegrationHost"] { + assert!( + !source.contains(marker), + "{} contains host-selection marker {marker}", + path.display() + ); + } + } +} + +#[test] +fn all_target_is_command_only() { + let src = source_root(); + for path in rust_files(&src) { + if path.starts_with(src.join("commands")) { + continue; + } + let source = fs::read_to_string(&path).unwrap(); + for marker in ["IntegrationHost", "InstallTarget", "CodingAgent::All"] { + assert!( + !source.contains(marker), + "{} contains command target marker {marker}", + path.display() + ); + } + } +} + +#[test] +fn shared_runtime_subsystems_do_not_dispatch_host_variants() { + let src = source_root(); + for subsystem in [ + "installation", + "process", + "configuration", + "diagnostics", + "gateway", + "sessions", + "hooks", + "filesystem", + ] { + for path in rust_files(&src.join(subsystem)) { + let source = fs::read_to_string(&path).unwrap(); + for marker in [ + "CodingAgent::Codex", + "CodingAgent::ClaudeCode", + "CodingAgent::Hermes", + ] { + assert!( + !source.contains(marker), + "{} dispatches host variant {marker}", + path.display() + ); + } + } + } +} diff --git a/crates/cli/tests/cli_tests.rs b/crates/cli/tests/cli_tests.rs index e926613d8..f34986490 100644 --- a/crates/cli/tests/cli_tests.rs +++ b/crates/cli/tests/cli_tests.rs @@ -3,13 +3,18 @@ //! CLI-level gateway coverage tests. -use std::io::{Read, Write}; -use std::net::TcpListener; -use std::process::{Command, Stdio}; +use std::io::{BufRead, BufReader, Read, Write}; +use std::net::{SocketAddr, TcpListener, TcpStream}; +use std::path::Path; +use std::process::{Child, ChildStdin, Command, ExitStatus, Output, Stdio}; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::mpsc; +use std::sync::{Arc, Mutex}; use std::thread; +use std::time::{Duration, Instant}; use base64::Engine; +use ring::hmac; use ring::rand::SystemRandom; use ring::signature::{Ed25519KeyPair, KeyPair}; use sha2::{Digest, Sha256}; @@ -18,6 +23,15 @@ fn gateway_bin() -> &'static str { env!("CARGO_BIN_EXE_nemo-relay") } +const ACTIVE_GENERATION_TOKEN: &str = "active-generation"; + +fn write_active_generation(temp: &std::path::Path) -> std::path::PathBuf { + let generation = temp.join("plugin/.nemo-relay-generation"); + std::fs::create_dir_all(generation.parent().unwrap()).unwrap(); + std::fs::write(&generation, format!("{ACTIVE_GENERATION_TOKEN}\n")).unwrap(); + generation +} + fn toml_basic_string(value: &str) -> String { let escaped = value .chars() @@ -105,6 +119,54 @@ entrypoint = "plugin.py" .unwrap(); } +fn write_python_dynamic_plugin_manifest(dir: &std::path::Path, plugin_id: &str) { + std::fs::create_dir_all(dir).unwrap(); + let artifact_body = "def main():\n return None\n"; + std::fs::write(dir.join("plugin.py"), artifact_body).unwrap(); + let digest = format!( + "sha256:{}", + Sha256::digest(artifact_body.as_bytes()) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::() + ); + std::fs::write( + dir.join("relay-plugin.toml"), + format!( + r#"manifest_version = 1 + +[plugin] +id = {plugin_id} +kind = "worker" + +[compat] +relay = "0.5" +worker_protocol = "grpc-v1" + +[defaults] +enabled = false + +[capabilities] +items = ["plugin_worker"] + +[source] +manifest_root = "." +artifact = "plugin.py" + +[integrity] +sha256 = {digest} + +[load] +runtime = "python" +entrypoint = "plugin:main" +"#, + plugin_id = toml_basic_string(plugin_id), + digest = toml_basic_string(&digest), + ), + ) + .unwrap(); +} + fn write_detached_ed25519_signature(dir: &std::path::Path, signature_name: &str) -> String { std::fs::create_dir_all(dir).unwrap(); let artifact = std::fs::read(dir.join("plugin.py")).unwrap(); @@ -157,8 +219,1328 @@ fn cli_version_exits_successfully() { .output() .unwrap(); - assert!(output.status.success()); - assert!(String::from_utf8_lossy(&output.stdout).contains("nemo-relay ")); + assert!(output.status.success()); + assert!(String::from_utf8_lossy(&output.stdout).contains("nemo-relay ")); +} + +#[test] +fn managed_mcp_launch_removes_unresolved_environment_placeholders_before_cli_parsing() { + let output = Command::new(gateway_bin()) + .env("NEMO_RELAY_MCP_GENERATION_FILE", "/tmp/managed-generation") + .env("NEMO_RELAY_MCP_GENERATION", "managed-token") + .env( + "NEMO_RELAY_MAX_HOOK_PAYLOAD_BYTES", + "${NEMO_RELAY_MAX_HOOK_PAYLOAD_BYTES}", + ) + .env( + "NEMO_RELAY_MAX_PASSTHROUGH_BODY_BYTES", + "${NEMO_RELAY_MAX_PASSTHROUGH_BODY_BYTES}", + ) + .args(["agents", "--json"]) + .output() + .unwrap(); + + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + serde_json::from_slice::(&output.stdout).unwrap(); +} + +#[test] +fn ordinary_cli_launch_does_not_hide_invalid_environment_values() { + let output = Command::new(gateway_bin()) + .env_remove("NEMO_RELAY_MCP_GENERATION_FILE") + .env_remove("NEMO_RELAY_MCP_GENERATION") + .env( + "NEMO_RELAY_MAX_HOOK_PAYLOAD_BYTES", + "${NEMO_RELAY_MAX_HOOK_PAYLOAD_BYTES}", + ) + .args(["agents", "--json"]) + .output() + .unwrap(); + + assert!(!output.status.success()); + assert!(String::from_utf8_lossy(&output.stderr).contains("invalid digit found in string")); +} + +#[test] +fn managed_mcp_launch_rejects_unresolved_generation_placeholders() { + let output = Command::new(gateway_bin()) + .env( + "NEMO_RELAY_MCP_GENERATION_FILE", + "${NEMO_RELAY_MCP_GENERATION_FILE}", + ) + .env("NEMO_RELAY_MCP_GENERATION", "${NEMO_RELAY_MCP_GENERATION}") + .arg("mcp") + .output() + .unwrap(); + + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("generation"), "{stderr}"); +} + +#[test] +fn cli_mcp_help_describes_lifecycle_bound_native_gateway() { + let output = Command::new(gateway_bin()) + .args(["mcp", "--help"]) + .output() + .unwrap(); + + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("Multiple MCP clients share the gateway")); + assert!(stdout.contains("127.0.0.1:47632")); +} + +#[test] +fn cli_config_help_keeps_hermes_persistent_state_under_uninstall() { + let output = Command::new(gateway_bin()) + .args(["config", "--help"]) + .output() + .unwrap(); + + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("nemo-relay uninstall hermes")); + assert!(!stdout.contains("Hermes-scoped reset also removes")); +} + +#[test] +fn cli_mcp_starts_gateway_before_initialize_and_exits_cleanly() { + let temp = tempfile::tempdir().unwrap(); + let mut child = Command::new(gateway_bin()) + .args(["--bind", "127.0.0.1:0", "mcp"]) + .env("HOME", temp.path()) + .env("XDG_CONFIG_HOME", temp.path().join("xdg")) + .env("XDG_RUNTIME_DIR", temp.path().join("runtime")) + .env("TMPDIR", temp.path()) + .env("NEMO_RELAY_PLUGIN_IDLE_TIMEOUT_SECS", "30") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + let owner = wait_for_owned_sidecar(temp.path(), None); + let address = sidecar_address(temp.path()); + let mut stdin = child.stdin.take().unwrap(); + stdin + .write_all( + b"{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{\"protocolVersion\":\"2025-06-18\"}}\n", + ) + .unwrap(); + drop(stdin); + + let output = wait_child_with_output(child); + + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let response = serde_json::from_slice::(&output.stdout).unwrap(); + assert_eq!(response["id"], serde_json::json!(1)); + assert_eq!( + response["result"]["serverInfo"]["name"], + serde_json::json!("nemo-relay") + ); + assert!(find_runtime_file(temp.path(), "gateway-sidecar.log").is_none()); + stop_owned_sidecar(&owner); + wait_for_port_closed(address); +} + +#[test] +fn cli_mcp_starts_gateway_even_when_stdio_closes_before_request() { + let temp = tempfile::tempdir().unwrap(); + let mut child = Command::new(gateway_bin()) + .args(["--bind", "127.0.0.1:0", "mcp"]) + .env("HOME", temp.path()) + .env("XDG_CONFIG_HOME", temp.path().join("xdg")) + .env("XDG_RUNTIME_DIR", temp.path().join("runtime")) + .env("TMPDIR", temp.path()) + .env("NEMO_RELAY_PLUGIN_IDLE_TIMEOUT_SECS", "30") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + drop(child.stdin.take()); + + let output = wait_child_with_output(child); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + assert!(output.stdout.is_empty()); + assert!(find_runtime_file(temp.path(), "gateway-sidecar.log").is_none()); + let owner = wait_for_owned_sidecar(temp.path(), None); + let address = sidecar_address(temp.path()); + stop_owned_sidecar(&owner); + wait_for_port_closed(address); +} + +#[test] +fn cli_mcp_rejects_an_unauthenticated_transparent_gateway() { + let temp = tempfile::tempdir().unwrap(); + let body = format!( + r#"{{"status":"ok","service":"nemo-relay","version":"{}","bootstrap_protocol":2,"instance_id":"transparent"}}"#, + env!("CARGO_PKG_VERSION") + ); + let (gateway_url, received) = spawn_single_request_server(200, &body); + let mut child = Command::new(gateway_bin()) + .args(["--bind", "127.0.0.1:1", "mcp"]) + .env("HOME", temp.path()) + .env("XDG_CONFIG_HOME", temp.path().join("xdg")) + .env("XDG_RUNTIME_DIR", temp.path().join("runtime")) + .env("TMPDIR", temp.path()) + .env("NEMO_RELAY_TRANSPARENT_RUN", "1") + .env("NEMO_RELAY_GATEWAY_URL", &gateway_url) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + child + .stdin + .take() + .unwrap() + .write_all( + b"{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{\"protocolVersion\":\"2025-06-18\"}}\n", + ) + .unwrap(); + + let output = wait_child_with_output(child); + assert!(!output.status.success()); + assert!( + String::from_utf8_lossy(&output.stderr).contains("authenticated NeMo Relay gateway"), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + assert!(output.stdout.is_empty()); + assert!( + received + .recv_timeout(Duration::from_secs(2)) + .unwrap() + .starts_with("GET /healthz ") + ); + assert!(find_runtime_file(temp.path(), "gateway-sidecar.log").is_none()); + assert!(find_runtime_file(temp.path(), "codex.owner.json").is_none()); +} + +#[cfg(unix)] +#[test] +fn cli_internal_hermes_install_writes_mcp_hooks_trust_and_doctor_ready_state() { + use std::os::unix::fs::PermissionsExt; + + let temp = tempfile::tempdir().unwrap(); + let home = temp.path().join("home"); + let hermes_home = temp.path().join("hermes"); + let xdg = temp.path().join("xdg"); + let runtime = temp.path().join("runtime"); + let bin = temp.path().join("bin"); + for directory in [&home, &hermes_home, &xdg, &runtime, &bin] { + std::fs::create_dir_all(directory).unwrap(); + } + let hermes = bin.join("hermes"); + std::fs::write(&hermes, "#!/bin/sh\necho 'Hermes Agent v0.18.2 (test)'\n").unwrap(); + std::fs::set_permissions(&hermes, std::fs::Permissions::from_mode(0o755)).unwrap(); + std::os::unix::fs::symlink(gateway_bin(), bin.join("nemo-relay")).unwrap(); + let path = std::env::join_paths(std::iter::once(bin.clone()).chain(std::env::split_paths( + &std::env::var_os("PATH").unwrap_or_default(), + ))) + .unwrap(); + + let install = Command::new(gateway_bin()) + .args(["install", "hermes", "--skip-doctor"]) + .env("HOME", &home) + .env("HERMES_HOME", &hermes_home) + .env("XDG_CONFIG_HOME", &xdg) + .env("XDG_RUNTIME_DIR", &runtime) + .env("PATH", &path) + .env("OPENAI_API_KEY", "not-written-to-config") + .output() + .unwrap(); + assert!( + install.status.success(), + "{}", + String::from_utf8_lossy(&install.stderr) + ); + + let config_path = hermes_home.join("config.yaml"); + let config: serde_json::Value = + serde_yaml::from_str(&std::fs::read_to_string(&config_path).unwrap()).unwrap(); + let server = &config["mcp_servers"]["nemo-relay"]; + assert_eq!(server["command"], gateway_bin()); + assert_eq!(server["args"], serde_json::json!(["mcp"])); + assert_eq!(server["env"]["NEMO_RELAY_GATEWAY_BIND"], "127.0.0.1:47632"); + assert_eq!(server["env"]["OPENAI_API_KEY"], "${OPENAI_API_KEY}"); + assert!( + !std::fs::read_to_string(&config_path) + .unwrap() + .contains("not-written-to-config") + ); + let command = config["hooks"]["on_session_start"][0]["command"] + .as_str() + .unwrap(); + assert!(command.contains("hook-forward hermes")); + let approvals: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(hermes_home.join("shell-hooks-allowlist.json")).unwrap(), + ) + .unwrap(); + let approvals = approvals["approvals"].as_array().unwrap(); + assert_eq!(approvals.len(), 13); + assert!(approvals.iter().all(|entry| entry["command"] == command)); + + let relay_config_dir = xdg.join("nemo-relay"); + std::fs::create_dir_all(&relay_config_dir).unwrap(); + std::fs::write( + relay_config_dir.join("config.toml"), + format!( + "[agents.hermes]\ncommand = {:?}\nhooks_path = {:?}\n", + hermes.display().to_string(), + config_path.display().to_string() + ), + ) + .unwrap(); + let doctor = Command::new(gateway_bin()) + .args(["doctor", "hermes", "--json"]) + .env("HOME", &home) + .env("HERMES_HOME", &hermes_home) + .env("XDG_CONFIG_HOME", &xdg) + .env("XDG_RUNTIME_DIR", &runtime) + .env("PATH", &path) + .env("OPENAI_API_KEY", "runtime-only") + .output() + .unwrap(); + assert!( + doctor.status.success(), + "{}", + String::from_utf8_lossy(&doctor.stderr) + ); + let report: serde_json::Value = serde_json::from_slice(&doctor.stdout).unwrap(); + assert_eq!(report["agents"][0]["name"], "hermes"); + assert_eq!(report["agents"][0]["status"], "pass"); + assert!( + report["agents"][0]["annotation"] + .as_str() + .unwrap() + .contains("MCP lifecycle") + ); + + let uninstall = Command::new(gateway_bin()) + .args(["uninstall", "hermes"]) + .env("HOME", &home) + .env("HERMES_HOME", &hermes_home) + .env("XDG_CONFIG_HOME", &xdg) + .env("PATH", &path) + .output() + .unwrap(); + assert!( + uninstall.status.success(), + "{}", + String::from_utf8_lossy(&uninstall.stderr) + ); + assert!(!config_path.exists()); + assert!(!hermes_home.join("shell-hooks-allowlist.json").exists()); + assert!(!hermes_home.join(".nemo-relay-generation").exists()); +} + +fn start_mcp_client(temp: &std::path::Path, bind: SocketAddr) -> (Child, ChildStdin) { + start_mcp_client_with_idle_timeout(temp, bind, "1") +} + +fn start_mcp_client_with_idle_timeout( + temp: &std::path::Path, + bind: SocketAddr, + idle_timeout_secs: &str, +) -> (Child, ChildStdin) { + start_mcp_client_with_generation(temp, bind, idle_timeout_secs, None) +} + +fn start_mcp_client_with_generation( + temp: &std::path::Path, + bind: SocketAddr, + idle_timeout_secs: &str, + generation: Option<&std::path::Path>, +) -> (Child, ChildStdin) { + let mut command = Command::new(gateway_bin()); + command + .args(["--bind", &bind.to_string(), "mcp"]) + .env("HOME", temp) + .env("XDG_CONFIG_HOME", temp.join("xdg")) + .env("XDG_RUNTIME_DIR", temp.join("runtime")) + .env("TMPDIR", temp) + .env("NEMO_RELAY_PLUGIN_IDLE_TIMEOUT_SECS", idle_timeout_secs); + if let Some(generation) = generation { + let token = std::fs::read_to_string(generation).unwrap(); + command + .env("NEMO_RELAY_MCP_GENERATION_FILE", generation) + .env("NEMO_RELAY_MCP_GENERATION", token.trim()); + } + let mut child = command + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + let mut stdin = child.stdin.take().unwrap(); + let stdout = child.stdout.take().unwrap(); + stdin + .write_all( + b"{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{\"protocolVersion\":\"2025-06-18\"}}\n", + ) + .unwrap(); + let (response_tx, response_rx) = mpsc::channel(); + thread::spawn(move || { + let mut response = String::new(); + let result = BufReader::new(stdout) + .read_line(&mut response) + .map(|_| response); + let _ = response_tx.send(result); + }); + let response = match response_rx.recv_timeout(Duration::from_secs(5)) { + Ok(response) => response.unwrap(), + Err(error) => { + let _ = child.kill(); + let _ = child.wait(); + panic!("MCP initialization response timed out: {error}"); + } + }; + let response: serde_json::Value = serde_json::from_str(&response).unwrap(); + assert_eq!(response["result"]["serverInfo"]["name"], "nemo-relay"); + (child, stdin) +} + +#[test] +fn cli_hooks_and_mcp_share_the_same_persistent_identity_for_each_host() { + for agent in ["codex", "claude", "hermes"] { + let temp = tempfile::tempdir().unwrap(); + let probe = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = probe.local_addr().unwrap(); + drop(probe); + let gateway_url = format!("http://{address}"); + let generation = write_active_generation(temp.path()); + let (mut mcp, mcp_stdin) = start_mcp_client_with_idle_timeout(temp.path(), address, "10"); + let owner = wait_for_owned_sidecar(temp.path(), None); + assert_eq!(owner["url"], gateway_url); + + let mut hook = Command::new(gateway_bin()) + .args(["hook-forward", agent, "--gateway-url", &gateway_url]) + .arg("--generation-file") + .arg(&generation) + .arg("--generation-token") + .arg(ACTIVE_GENERATION_TOKEN) + .env("HOME", temp.path()) + .env("XDG_CONFIG_HOME", temp.path().join("xdg")) + .env("XDG_RUNTIME_DIR", temp.path().join("runtime")) + .env("TMPDIR", temp.path()) + .env("NEMO_RELAY_PLUGIN_IDLE_TIMEOUT_SECS", "10") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + hook.stdin + .take() + .unwrap() + .write_all(b"{\"session_id\":\"cold-hook\",\"hook_event_name\":\"SessionStart\"}") + .unwrap(); + let hook_output = wait_child_with_output(hook); + assert!( + hook_output.status.success(), + "{agent} hook failed: {}", + String::from_utf8_lossy(&hook_output.stderr) + ); + drop(mcp_stdin); + assert!(wait_child(&mut mcp).success()); + stop_owned_sidecar(&owner); + wait_for_port_closed(address); + } +} + +#[derive(Clone, Copy)] +enum FakeBootstrapProof { + Missing, + Wrong, + Valid, +} + +fn bootstrap_request_header<'a>(request: &'a str, name: &str) -> Option<&'a str> { + request.lines().find_map(|line| { + let (candidate, value) = line.split_once(':')?; + candidate.eq_ignore_ascii_case(name).then(|| value.trim()) + }) +} + +fn fake_bootstrap_proof(key: &[u8], fingerprint: &str, nonce: &str) -> String { + let key = hmac::Key::new(hmac::HMAC_SHA256, key); + let mut context = hmac::Context::with_key(&key); + context.update(b"nemo-relay/bootstrap-health/v1\0"); + context.update(fingerprint.as_bytes()); + context.update(&[0]); + context.update(nonce.as_bytes()); + format!( + "hmac-sha256:{}", + context + .sign() + .as_ref() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::() + ) +} + +fn write_test_tls_identity(bootstrap_dir: &Path) -> Arc { + let certified = rcgen::generate_simple_self_signed(vec!["localhost".into()]).unwrap(); + std::fs::create_dir_all(bootstrap_dir).unwrap(); + std::fs::write( + bootstrap_dir.join("hook-tls-identity.json"), + serde_json::to_vec(&serde_json::json!({ + "certificate_der": certified.cert.der().to_vec(), + "private_key_der": certified.key_pair.serialize_der(), + })) + .unwrap(), + ) + .unwrap(); + Arc::new( + rustls::ServerConfig::builder() + .with_no_client_auth() + .with_single_cert( + vec![certified.cert.der().clone()], + rustls::pki_types::PrivateKeyDer::Pkcs8( + rustls::pki_types::PrivatePkcs8KeyDer::from(certified.key_pair.serialize_der()), + ), + ) + .unwrap(), + ) +} + +fn run_fake_bootstrap_listener(proof: FakeBootstrapProof) -> (Output, Vec) { + run_fake_bootstrap_listener_with_options(proof, None, false) +} + +fn run_fake_bootstrap_listener_with_hook_delay( + proof: FakeBootstrapProof, + hook_delay: Option, +) -> (Output, Vec) { + run_fake_bootstrap_listener_with_options(proof, hook_delay, false) +} + +fn run_forward_only_fake_bootstrap_listener(proof: FakeBootstrapProof) -> (Output, Vec) { + run_fake_bootstrap_listener_with_options(proof, None, true) +} + +fn run_fake_bootstrap_listener_with_options( + proof: FakeBootstrapProof, + hook_delay: Option, + forward_only: bool, +) -> (Output, Vec) { + let temp = tempfile::tempdir().unwrap(); + let generation = write_active_generation(temp.path()); + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + listener.set_nonblocking(true).unwrap(); + let address = listener.local_addr().unwrap(); + let stopped = Arc::new(AtomicBool::new(false)); + let requests = Arc::new(Mutex::new(Vec::new())); + let server_stopped = stopped.clone(); + let server_requests = requests.clone(); + let key_path = temp + .path() + .join("xdg") + .join("nemo-relay") + .join("bootstrap") + .join("fingerprint-hmac.key"); + let tls = write_test_tls_identity(key_path.parent().unwrap()); + let server = thread::spawn(move || { + while !server_stopped.load(Ordering::Relaxed) { + let (mut stream, _) = match listener.accept() { + Ok(connection) => connection, + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + thread::sleep(Duration::from_millis(5)); + continue; + } + Err(error) => panic!("fake bootstrap listener failed: {error}"), + }; + stream.set_nonblocking(false).unwrap(); + stream + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + let request = read_http_request(&mut stream); + server_requests.lock().unwrap().push(request.clone()); + if request.starts_with("GET /healthz ") { + let fingerprint = + bootstrap_request_header(&request, "x-nemo-relay-bootstrap-fingerprint") + .unwrap(); + let nonce = + bootstrap_request_header(&request, "x-nemo-relay-bootstrap-nonce").unwrap(); + let proof_header = match proof { + FakeBootstrapProof::Missing => String::new(), + FakeBootstrapProof::Wrong => { + "X-NeMo-Relay-Bootstrap-Proof: hmac-sha256:0000000000000000000000000000000000000000000000000000000000000000\r\n".into() + } + FakeBootstrapProof::Valid => format!( + "X-NeMo-Relay-Bootstrap-Proof: {}\r\n", + fake_bootstrap_proof( + &std::fs::read(&key_path).unwrap(), + fingerprint, + nonce + ) + ), + }; + let body = format!( + r#"{{"status":"ok","service":"nemo-relay","version":"{}","bootstrap_protocol":2,"instance_id":"test-instance"}}"#, + env!("CARGO_PKG_VERSION") + ); + stream + .write_all( + format!( + "HTTP/1.1 200 OK\r\n{proof_header}Content-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ) + .as_bytes(), + ) + .unwrap(); + continue; + } + if request.starts_with("GET /bootstrap/tunnel ") { + let fingerprint = + bootstrap_request_header(&request, "x-nemo-relay-bootstrap-fingerprint") + .unwrap(); + let nonce = + bootstrap_request_header(&request, "x-nemo-relay-bootstrap-nonce").unwrap(); + let proof_header = match proof { + FakeBootstrapProof::Missing => String::new(), + FakeBootstrapProof::Wrong => { + "X-NeMo-Relay-Bootstrap-Proof: hmac-sha256:0000000000000000000000000000000000000000000000000000000000000000\r\n".into() + } + FakeBootstrapProof::Valid => { + let key = std::fs::read(&key_path).unwrap(); + format!( + "X-NeMo-Relay-Bootstrap-Proof: {}\r\n", + fake_bootstrap_proof(&key, fingerprint, nonce) + ) + } + }; + stream + .write_all( + format!( + "HTTP/1.1 101 Switching Protocols\r\n{proof_header}Connection: upgrade\r\nUpgrade: nemo-relay-tls\r\nContent-Length: 0\r\n\r\n" + ) + .as_bytes(), + ) + .unwrap(); + if matches!(proof, FakeBootstrapProof::Valid) { + let connection = rustls::ServerConnection::new(tls.clone()).unwrap(); + let mut stream = rustls::StreamOwned::new(connection, stream); + let health = read_http_request(&mut stream); + server_requests.lock().unwrap().push(health); + let body = format!( + r#"{{"status":"ok","service":"nemo-relay","version":"{}","bootstrap_protocol":2,"instance_id":"test-instance"}}"#, + env!("CARGO_PKG_VERSION") + ); + stream + .write_all( + format!( + "HTTP/1.1 200 OK\r\nX-NeMo-Relay-Bootstrap-Proof: {}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: keep-alive\r\n\r\n{body}", + fake_bootstrap_proof( + &std::fs::read(&key_path).unwrap(), + fingerprint, + nonce + ), + body.len() + ) + .as_bytes(), + ) + .unwrap(); + let hook = read_http_request(&mut stream); + server_requests.lock().unwrap().push(hook); + if let Some(delay) = hook_delay { + thread::sleep(delay); + } + let body = r#"{"continue":true}"#; + let _ = stream.write_all( + format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ) + .as_bytes(), + ); + } + } else { + if let Some(delay) = hook_delay { + thread::sleep(delay); + } + let body = r#"{"continue":true}"#; + let _ = stream.write_all( + format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ) + .as_bytes(), + ); + } + } + }); + + let mut command = Command::new(gateway_bin()); + command.args([ + "hook-forward", + "codex", + "--gateway-url", + &format!("http://{address}"), + ]); + if forward_only { + command.arg("--forward-only"); + } else { + command + .arg("--generation-file") + .arg(&generation) + .arg("--generation-token") + .arg(ACTIVE_GENERATION_TOKEN); + } + let mut child = command + .env("HOME", temp.path()) + .env("XDG_CONFIG_HOME", temp.path().join("xdg")) + .env("XDG_RUNTIME_DIR", temp.path().join("runtime")) + .env("TMPDIR", temp.path()) + .env("NEMO_RELAY_FAIL_CLOSED", "1") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + child + .stdin + .take() + .unwrap() + .write_all(b"{\"session_id\":\"challenge\",\"hook_event_name\":\"SessionStart\"}") + .unwrap(); + let output = wait_child_with_output(child); + stopped.store(true, Ordering::Relaxed); + server.join().unwrap(); + let requests = Arc::try_unwrap(requests).unwrap().into_inner().unwrap(); + (output, requests) +} + +#[test] +fn cli_codex_hook_rejects_compatible_json_without_bootstrap_proof() { + let (output, requests) = run_fake_bootstrap_listener(FakeBootstrapProof::Missing); + assert!(!output.status.success()); + assert!(requests.iter().all(|request| !request.starts_with("POST "))); +} + +#[test] +fn cli_codex_hook_rejects_an_invalid_bootstrap_proof() { + let (output, requests) = run_fake_bootstrap_listener(FakeBootstrapProof::Wrong); + assert!(!output.status.success()); + assert!(requests.iter().all(|request| !request.starts_with("POST "))); +} + +#[test] +fn cli_codex_hook_reuses_a_listener_with_a_valid_bootstrap_proof() { + let (output, requests) = run_fake_bootstrap_listener(FakeBootstrapProof::Valid); + assert!( + output.status.success(), + "authenticated hook failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert!( + requests + .iter() + .any(|request| request.starts_with("POST /hooks/codex ")) + ); +} + +#[test] +fn cli_codex_hook_does_not_retry_an_ambiguous_response_timeout() { + let (output, requests) = run_fake_bootstrap_listener_with_hook_delay( + FakeBootstrapProof::Valid, + Some(Duration::from_millis(2_500)), + ); + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("hook forward failed"), "{stderr}"); + assert!(!stderr.contains("sidecar recovery"), "{stderr}"); + assert_eq!( + requests + .iter() + .filter(|request| request.starts_with("POST /hooks/codex ")) + .count(), + 1 + ); + assert!( + requests + .last() + .is_some_and(|request| request.starts_with("POST ")) + ); +} + +fn run_codex_hook_with_launch_resolution_error( + temp: &std::path::Path, + fail_closed: bool, + payload: &[u8], + invalid_idle_timeout: bool, +) -> Output { + let generation = write_active_generation(temp); + let mut command = Command::new(gateway_bin()); + command + .args([ + "hook-forward", + "codex", + "--gateway-url", + "http://127.0.0.1:1", + ]) + .arg("--generation-file") + .arg(generation) + .arg("--generation-token") + .arg(ACTIVE_GENERATION_TOKEN) + .env("HOME", temp) + .env("XDG_CONFIG_HOME", temp.join("xdg")) + .env("XDG_RUNTIME_DIR", temp.join("runtime")) + .env("TMPDIR", temp) + .env_remove("NEMO_RELAY_FAIL_CLOSED") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + if invalid_idle_timeout { + command.env("NEMO_RELAY_PLUGIN_IDLE_TIMEOUT_SECS", "not-a-number"); + } + if fail_closed { + command.env("NEMO_RELAY_FAIL_CLOSED", "1"); + } + let mut child = command.spawn().unwrap(); + child.stdin.take().unwrap().write_all(payload).unwrap(); + wait_child_with_output(child) +} + +#[test] +fn cli_codex_hook_launch_resolution_error_respects_forwarding_policy() { + let temp = tempfile::tempdir().unwrap(); + + for fail_closed in [false, true] { + let output = + run_codex_hook_with_launch_resolution_error(temp.path(), fail_closed, b"{}", true); + assert_eq!(output.status.success(), !fail_closed); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("NEMO_RELAY_PLUGIN_IDLE_TIMEOUT_SECS")); + assert!(output.stdout.is_empty()); + } +} + +#[test] +fn cli_codex_hook_launch_resolution_error_retains_default_payload_cap() { + const DEFAULT_HOOK_PAYLOAD_BYTES: usize = 20 * 1024 * 1024; + let temp = tempfile::tempdir().unwrap(); + let payload = vec![b'x'; DEFAULT_HOOK_PAYLOAD_BYTES + 1]; + + for fail_closed in [false, true] { + let output = + run_codex_hook_with_launch_resolution_error(temp.path(), fail_closed, &payload, false); + + assert_eq!(output.status.success(), !fail_closed); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("hook payload exceeds the 20971520-byte limit")); + assert!(!stderr.contains("sidecar recovery")); + assert!(output.stdout.is_empty()); + } +} + +fn sidecar_address(temp: &std::path::Path) -> SocketAddr { + let deadline = Instant::now() + Duration::from_secs(5); + loop { + for path in find_runtime_files_matching(temp, "sidecar-", ".owner.json") { + if let Ok(raw) = std::fs::read(path) + && let Ok(owner) = serde_json::from_slice::(&raw) + && let Some(address) = owner["url"] + .as_str() + .and_then(|url| url.strip_prefix("http://")) + .and_then(|address| address.parse().ok()) + { + return address; + } + } + assert!( + Instant::now() < deadline, + "sidecar ownership was not published under {}", + temp.display() + ); + thread::sleep(Duration::from_millis(20)); + } +} + +fn find_runtime_file(root: &std::path::Path, name: &str) -> Option { + let mut pending = vec![root.to_path_buf()]; + while let Some(directory) = pending.pop() { + let entries = std::fs::read_dir(directory).ok()?; + for entry in entries.flatten() { + let path = entry.path(); + if path.file_name().and_then(|value| value.to_str()) == Some(name) { + return Some(path); + } + if path.is_dir() { + pending.push(path); + } + } + } + None +} + +fn find_runtime_files_matching( + root: &std::path::Path, + prefix: &str, + suffix: &str, +) -> Vec { + let mut matches = Vec::new(); + let mut pending = vec![root.to_path_buf()]; + while let Some(directory) = pending.pop() { + let Ok(entries) = std::fs::read_dir(directory) else { + continue; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + pending.push(path); + continue; + } + if path + .file_name() + .and_then(|value| value.to_str()) + .is_some_and(|name| name.starts_with(prefix) && name.ends_with(suffix)) + { + matches.push(path); + } + } + } + matches +} + +fn wait_child(child: &mut Child) -> ExitStatus { + let deadline = Instant::now() + Duration::from_secs(10); + loop { + if let Some(status) = child.try_wait().unwrap() { + return status; + } + if Instant::now() >= deadline { + let _ = child.kill(); + let _ = child.wait(); + panic!("child process did not exit within 10 seconds"); + } + thread::sleep(Duration::from_millis(20)); + } +} + +fn wait_child_with_output(mut child: Child) -> Output { + fn read_pipe( + pipe: Option, + ) -> mpsc::Receiver>> { + let (sender, receiver) = mpsc::channel(); + thread::spawn(move || { + let result = match pipe { + Some(mut pipe) => { + let mut bytes = Vec::new(); + pipe.read_to_end(&mut bytes).map(|_| bytes) + } + None => Ok(Vec::new()), + }; + let _ = sender.send(result); + }); + receiver + } + + let stdout = read_pipe(child.stdout.take()); + let stderr = read_pipe(child.stderr.take()); + let deadline = Instant::now() + Duration::from_secs(10); + let status = loop { + if let Some(status) = child.try_wait().unwrap() { + break status; + } + if Instant::now() >= deadline { + let _ = child.kill(); + let _ = child.wait(); + panic!("child process did not exit within 10 seconds"); + } + thread::sleep(Duration::from_millis(20)); + }; + let remaining = || deadline.saturating_duration_since(Instant::now()); + let stdout = stdout + .recv_timeout(remaining()) + .expect("child stdout remained open after process exit") + .unwrap(); + let stderr = stderr + .recv_timeout(remaining()) + .expect("child stderr remained open after process exit") + .unwrap(); + Output { + status, + stdout, + stderr, + } +} + +fn run_persistent_hook( + temp: &std::path::Path, + address: SocketAddr, + generation: &std::path::Path, + fail_closed: bool, +) -> Output { + run_persistent_hook_with_token( + temp, + address, + generation, + ACTIVE_GENERATION_TOKEN, + fail_closed, + ) +} + +fn run_persistent_hook_with_token( + temp: &std::path::Path, + address: SocketAddr, + generation: &std::path::Path, + generation_token: &str, + fail_closed: bool, +) -> Output { + let mut command = Command::new(gateway_bin()); + command + .args([ + "hook-forward", + "codex", + "--gateway-url", + &format!("http://{address}"), + "--generation-file", + ]) + .arg(generation) + .arg("--generation-token") + .arg(generation_token) + .env("HOME", temp) + .env("XDG_CONFIG_HOME", temp.join("xdg")) + .env("XDG_RUNTIME_DIR", temp.join("runtime")) + .env("TMPDIR", temp) + .env("NEMO_RELAY_PLUGIN_IDLE_TIMEOUT_SECS", "1") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + if fail_closed { + command.arg("--fail-closed"); + } + let mut child = command.spawn().unwrap(); + child + .stdin + .take() + .unwrap() + .write_all(b"{\"session_id\":\"hook-session\",\"hook_event_name\":\"SessionStart\"}") + .unwrap(); + wait_child_with_output(child) +} + +fn wait_for_port_closed(address: SocketAddr) { + let deadline = Instant::now() + Duration::from_secs(10); + loop { + if TcpStream::connect_timeout(&address, Duration::from_millis(100)).is_err() { + return; + } + assert!( + Instant::now() < deadline, + "shared gateway remained bound after the final MCP client and idle timeout" + ); + thread::sleep(Duration::from_millis(20)); + } +} + +fn wait_for_owned_sidecar(temp: &std::path::Path, previous_pid: Option) -> serde_json::Value { + let deadline = Instant::now() + Duration::from_secs(10); + loop { + for path in find_runtime_files_matching(temp, "sidecar-", ".owner.json") { + if let Ok(raw) = std::fs::read(path) + && let Ok(owner) = serde_json::from_slice::(&raw) + && owner["pid"] + .as_u64() + .is_some_and(|pid| Some(pid) != previous_pid) + { + return owner; + } + } + assert!( + Instant::now() < deadline, + "owned gateway sidecar was not published under {}", + temp.display() + ); + thread::sleep(Duration::from_millis(20)); + } +} + +fn stop_owned_sidecar(owner: &serde_json::Value) { + let address = owner["url"] + .as_str() + .unwrap() + .strip_prefix("http://") + .unwrap() + .parse::() + .unwrap(); + let token = owner["shutdown_token"].as_str().unwrap(); + let mut stream = TcpStream::connect_timeout(&address, Duration::from_secs(2)).unwrap(); + stream + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + stream + .set_write_timeout(Some(Duration::from_secs(2))) + .unwrap(); + stream + .write_all( + format!( + "POST /bootstrap/shutdown HTTP/1.1\r\nHost: {address}\r\nX-NeMo-Relay-Bootstrap-Token: {token}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" + ) + .as_bytes(), + ) + .unwrap(); + let mut response = String::new(); + stream.read_to_string(&mut response).unwrap(); + assert!(response.starts_with("HTTP/1.1 204"), "{response}"); +} + +fn relay_health(address: SocketAddr) -> serde_json::Value { + let mut stream = TcpStream::connect_timeout(&address, Duration::from_secs(2)).unwrap(); + stream + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + stream + .write_all( + format!("GET /healthz HTTP/1.1\r\nHost: {address}\r\nConnection: close\r\n\r\n") + .as_bytes(), + ) + .unwrap(); + let mut response = String::new(); + stream.read_to_string(&mut response).unwrap(); + serde_json::from_str(response.split("\r\n\r\n").nth(1).unwrap()).unwrap() +} + +#[test] +fn cli_mcp_clients_share_gateway_until_final_idle_shutdown() { + let temp = tempfile::tempdir().unwrap(); + let (mut first, first_stdin) = start_mcp_client(temp.path(), "127.0.0.1:0".parse().unwrap()); + let address = sidecar_address(temp.path()); + let (mut second, second_stdin) = start_mcp_client(temp.path(), address); + + drop(first_stdin); + assert!(wait_child(&mut first).success()); + let health = relay_health(address); + assert_eq!(health["service"], "nemo-relay"); + assert_eq!(health["version"], env!("CARGO_PKG_VERSION")); + assert_eq!(health["bootstrap_protocol"], 2); + assert!( + health["instance_id"] + .as_str() + .is_some_and(|instance_id| !instance_id.is_empty()), + "the shared gateway should publish its process identity" + ); + + drop(second_stdin); + assert!(wait_child(&mut second).success()); + wait_for_port_closed(address); +} + +#[test] +fn cli_mcp_restarts_one_stopped_gateway_then_fails_after_the_second_stop() { + let temp = tempfile::tempdir().unwrap(); + let (mut client, _stdin) = start_mcp_client(temp.path(), "127.0.0.1:0".parse().unwrap()); + let first = wait_for_owned_sidecar(temp.path(), None); + let first_pid = first["pid"].as_u64().unwrap(); + + stop_owned_sidecar(&first); + let second = wait_for_owned_sidecar(temp.path(), Some(first_pid)); + assert_ne!(second["pid"], first["pid"]); + + stop_owned_sidecar(&second); + let status = wait_child(&mut client); + assert!( + !status.success(), + "MCP client unexpectedly restarted the shared gateway twice" + ); +} + +#[test] +fn cli_mcp_staggered_clients_share_one_endpoint_restart_budget() { + let temp = tempfile::tempdir().unwrap(); + let probe = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = probe.local_addr().unwrap(); + drop(probe); + let (mut fast_client, _fast_stdin) = + start_mcp_client_with_idle_timeout(temp.path(), address, "1"); + let first = wait_for_owned_sidecar(temp.path(), None); + let first_pid = first["pid"].as_u64().unwrap(); + // Offset the heartbeat phases while keeping the persistent fingerprint identical. + thread::sleep(Duration::from_millis(150)); + let (mut slow_client, _slow_stdin) = + start_mcp_client_with_idle_timeout(temp.path(), address, "1"); + + stop_owned_sidecar(&first); + let second = wait_for_owned_sidecar(temp.path(), Some(first_pid)); + let second_pid = second["pid"].as_u64().unwrap(); + stop_owned_sidecar(&second); + + assert!(!wait_child(&mut fast_client).success()); + assert!(!wait_child(&mut slow_client).success()); + let unexpected = find_runtime_files_matching(temp.path(), "sidecar-", ".owner.json") + .into_iter() + .filter_map(|path| std::fs::read(path).ok()) + .filter_map(|raw| serde_json::from_slice::(&raw).ok()) + .any(|owner| { + owner["pid"] + .as_u64() + .is_some_and(|pid| pid != first_pid && pid != second_pid) + }); + assert!( + !unexpected, + "a staggered MCP client restarted the endpoint a second time" + ); +} + +#[test] +fn cli_retired_persistent_hook_cannot_restart_the_gateway() { + let temp = tempfile::tempdir().unwrap(); + let probe = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = probe.local_addr().unwrap(); + drop(probe); + let generation = temp.path().join("plugin/.nemo-relay-generation"); + std::fs::create_dir_all(generation.parent().unwrap()).unwrap(); + std::fs::write(&generation, "retired:old-generation\n").unwrap(); + + let hook = run_persistent_hook(temp.path(), address, &generation, true); + + assert!(!hook.status.success()); + assert!( + String::from_utf8_lossy(&hook.stderr).contains("has been retired"), + "{}", + String::from_utf8_lossy(&hook.stderr) + ); + TcpListener::bind(address).expect("a retired hook unexpectedly bound the gateway endpoint"); +} + +#[test] +fn cli_cached_mcp_and_hook_cannot_adopt_a_replacement_at_the_same_generation_path() { + let temp = tempfile::tempdir().unwrap(); + let generation = temp.path().join("plugin/.nemo-relay-generation"); + std::fs::create_dir_all(generation.parent().unwrap()).unwrap(); + let cached_token = "cached-generation"; + std::fs::write(&generation, "replacement-generation\n").unwrap(); + + let probe = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = probe.local_addr().unwrap(); + drop(probe); + let mcp = Command::new(gateway_bin()) + .args(["--bind", &address.to_string(), "mcp"]) + .env("HOME", temp.path()) + .env("XDG_CONFIG_HOME", temp.path().join("xdg")) + .env("XDG_RUNTIME_DIR", temp.path().join("runtime")) + .env("TMPDIR", temp.path()) + .env("NEMO_RELAY_MCP_GENERATION_FILE", &generation) + .env("NEMO_RELAY_MCP_GENERATION", cached_token) + .output() + .unwrap(); + assert!(!mcp.status.success()); + assert!( + String::from_utf8_lossy(&mcp.stderr).contains("has been retired"), + "{}", + String::from_utf8_lossy(&mcp.stderr) + ); + drop(TcpListener::bind(address).expect("a stale MCP unexpectedly started the gateway")); + + let hook = + run_persistent_hook_with_token(temp.path(), address, &generation, cached_token, true); + assert!(!hook.status.success()); + assert!( + String::from_utf8_lossy(&hook.stderr).contains("has been retired"), + "{}", + String::from_utf8_lossy(&hook.stderr) + ); + TcpListener::bind(address).expect("a stale hook unexpectedly started the gateway"); +} + +#[test] +fn cli_unfenced_persistent_hook_cannot_start_the_gateway() { + let temp = tempfile::tempdir().unwrap(); + let probe = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = probe.local_addr().unwrap(); + drop(probe); + let gateway_url = format!("http://{address}"); + let mut hook = Command::new(gateway_bin()) + .args([ + "hook-forward", + "codex", + "--gateway-url", + &gateway_url, + "--fail-closed", + ]) + .env("HOME", temp.path()) + .env("XDG_CONFIG_HOME", temp.path().join("xdg")) + .env("XDG_RUNTIME_DIR", temp.path().join("runtime")) + .env("TMPDIR", temp.path()) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + hook.stdin + .take() + .unwrap() + .write_all(b"{\"session_id\":\"legacy-hook\",\"hook_event_name\":\"SessionStart\"}") + .unwrap(); + + let output = wait_child_with_output(hook); + + assert!(!output.status.success()); + assert!( + String::from_utf8_lossy(&output.stderr).contains("missing its install-generation fence"), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + TcpListener::bind(address).expect("an unfenced hook unexpectedly started the gateway"); +} + +#[test] +fn cli_path_only_persistent_hook_requires_its_expected_generation_identity() { + let temp = tempfile::tempdir().unwrap(); + let generation = write_active_generation(temp.path()); + let probe = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = probe.local_addr().unwrap(); + drop(probe); + let mut hook = Command::new(gateway_bin()) + .args([ + "hook-forward", + "codex", + "--gateway-url", + &format!("http://{address}"), + "--generation-file", + ]) + .arg(&generation) + .arg("--fail-closed") + .env("HOME", temp.path()) + .env("XDG_CONFIG_HOME", temp.path().join("xdg")) + .env("XDG_RUNTIME_DIR", temp.path().join("runtime")) + .env("TMPDIR", temp.path()) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + hook.stdin + .take() + .unwrap() + .write_all(b"{\"session_id\":\"legacy-hook\",\"hook_event_name\":\"SessionStart\"}") + .unwrap(); + + let output = wait_child_with_output(hook); + + assert!(!output.status.success()); + assert!( + String::from_utf8_lossy(&output.stderr) + .contains("missing its expected install-generation identity"), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + TcpListener::bind(address).expect("a path-only hook unexpectedly started the gateway"); } #[test] @@ -231,6 +1613,61 @@ fn cli_plugins_validate_json_emits_versioned_success_output() { assert_eq!(parsed["data"]["attestation_mode"], "integrity_only"); } +#[test] +fn cli_plugins_validate_rejects_malformed_python_entrypoints_by_path_and_id() { + let temp = tempfile::tempdir().unwrap(); + let cwd = temp.path().join("workdir"); + let plugin_dir = cwd.join("plugins").join("acme"); + let config_dir = cwd.join(".nemo-relay"); + let plugin_id = "acme.invalid-python-entrypoint"; + std::fs::create_dir_all(&config_dir).unwrap(); + write_python_dynamic_plugin_manifest(&plugin_dir, plugin_id); + std::fs::write( + config_dir.join("plugins.toml"), + format!( + "[[plugins.dynamic]]\nmanifest = {}\n", + toml_basic_string(plugin_dir.to_string_lossy().as_ref()) + ), + ) + .unwrap(); + + let manifest_path = plugin_dir.join("relay-plugin.toml"); + let manifest = std::fs::read_to_string(&manifest_path).unwrap(); + std::fs::write( + manifest_path, + manifest.replace("entrypoint = \"plugin:main\"", "entrypoint = \"plugin\""), + ) + .unwrap(); + + for target in [ + plugin_dir.to_string_lossy().into_owned(), + plugin_id.to_owned(), + ] { + let output = Command::new(gateway_bin()) + .current_dir(&cwd) + .env("XDG_CONFIG_HOME", temp.path().join("xdg")) + .env("HOME", temp.path()) + .args(["plugins", "validate", &target, "--json"]) + .output() + .unwrap(); + + assert!( + !output.status.success(), + "malformed Python entrypoint unexpectedly validated for {target}" + ); + let parsed: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(parsed["command"], "plugins validate"); + assert!( + parsed["error"]["message"] + .as_str() + .unwrap() + .contains("module:function form"), + "{}", + String::from_utf8_lossy(&output.stdout) + ); + } +} + #[test] fn cli_plugins_list_json_emits_empty_versioned_success_output() { let temp = tempfile::tempdir().unwrap(); @@ -1197,6 +2634,17 @@ fn cli_rejects_removed_cursor_entry_points() { assert!(String::from_utf8_lossy(&output.stderr).contains("invalid value 'cursor'")); } +#[test] +fn cli_rejects_removed_plugin_shim_entry_point() { + let output = Command::new(gateway_bin()) + .args(["plugin-shim", "--help"]) + .output() + .unwrap(); + + assert_eq!(output.status.code(), Some(2)); + assert!(String::from_utf8_lossy(&output.stderr).contains("unrecognized subcommand")); +} + #[test] fn cli_help_lists_model_pricing_command_only() { let output = Command::new(gateway_bin()).arg("--help").output().unwrap(); @@ -1272,7 +2720,7 @@ fn cli_install_dry_run_plans_local_codex_marketplace() { "stdout was:\n{stdout}" ); assert!( - stdout.contains("configure Codex provider and hook-supervised lazy startup"), + stdout.contains("configure Codex provider and trust plugin-owned hooks"), "stdout was:\n{stdout}" ); } @@ -1615,38 +3063,366 @@ fn cli_run_rejects_zero_body_limit_env() { assert!(stderr.contains("greater than 0")); } +#[cfg(unix)] #[test] -fn cli_hook_forward_fails_open_without_gateway_url() { - let mut child = Command::new(gateway_bin()) - .env_remove("NEMO_RELAY_GATEWAY_URL") - .args(["hook-forward", "codex"]) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() +fn cli_transparent_run_preserves_interactive_terminal_job_control() { + use std::os::unix::fs::PermissionsExt; + + let temp = tempfile::tempdir().unwrap(); + let home = temp.path().join("home"); + let xdg = temp.path().join("xdg"); + let runtime = temp.path().join("runtime"); + for directory in [&home, &xdg, &runtime] { + std::fs::create_dir_all(directory).unwrap(); + } + let agent = temp.path().join("interactive-codex"); + std::fs::write( + &agent, + r#"#!/usr/bin/env python3 +import os +import signal +import sys +import time + +delay_after_continue = False + +def handle_continue(_signal, _frame): + if delay_after_continue: + os.write(sys.stdout.fileno(), b"AGENT_BG_DELAY\n") + time.sleep(1.5) + +signal.signal(signal.SIGCONT, handle_continue) + +if sys.argv[1:] == ["--version"]: + print("codex-cli 0.143.0") + raise SystemExit(0) + +print(f"AGENT_READY:{os.getppid()}:{os.getpgrp()}", flush=True) +for line in sys.stdin: + value = line.strip() + if value == "delay-next-continue": + delay_after_continue = True + print("AGENT_DELAY_ARMED", flush=True) + else: + print(f"AGENT_READ:{value}", flush=True) +"#, + ) + .unwrap(); + std::fs::set_permissions(&agent, std::fs::Permissions::from_mode(0o755)).unwrap(); + let config = temp.path().join("config.toml"); + std::fs::write( + &config, + format!( + "[agents.codex]\ncommand = {}\n", + toml_basic_string(agent.to_string_lossy().as_ref()) + ), + ) + .unwrap(); + let driver = temp.path().join("pty-driver.py"); + std::fs::write( + &driver, + r#"import errno +import os +import pty +import re +import select +import signal +import shlex +import sys +import termios +import time + +relay, config, home, xdg, runtime = sys.argv[1:] +pid, master = pty.fork() +if pid == 0: + env = os.environ.copy() + env.update({ + "HOME": home, + "XDG_CONFIG_HOME": xdg, + "XDG_RUNTIME_DIR": runtime, + "NEMO_RELAY_PLUGIN_IDLE_TIMEOUT_SECS": "1", + "PS1": "RELAY_SHELL> ", + "ENV": "/dev/null", + "BASH_ENV": "/dev/null", + }) + os.execve("/bin/sh", ["sh", "-i"], env) + +attributes = termios.tcgetattr(master) +attributes[3] &= ~(termios.ECHO | getattr(termios, "ECHONL", 0)) +termios.tcsetattr(master, termios.TCSANOW, attributes) + +buffer = bytearray() +cursor = 0 +reaped = False +master_open = True +relay_pid = None +relay_group = None +agent_group = None + +def read_until(token, timeout=10): + global cursor + deadline = time.monotonic() + timeout + expected = token.encode() + while time.monotonic() < deadline: + index = buffer.find(expected, cursor) + if index >= 0: + observed = bytes(buffer[cursor:index + len(expected)]) + cursor = index + len(expected) + return observed.decode(errors="replace") + ready, _, _ = select.select([master], [], [], 0.1) + if not ready: + continue + try: + chunk = os.read(master, 4096) + except OSError as error: + if error.errno == errno.EIO: + break + raise + if not chunk: + break + buffer.extend(chunk) + raise AssertionError(f"did not observe {token!r}; output={buffer.decode(errors='replace')!r}") + +def wait_until_present(token, timeout=10): + deadline = time.monotonic() + timeout + expected = token.encode() + while time.monotonic() < deadline: + if expected in buffer: + return + ready, _, _ = select.select([master], [], [], 0.1) + if not ready: + continue + chunk = os.read(master, 4096) + if not chunk: + break + buffer.extend(chunk) + raise AssertionError(f"did not observe {token!r}; output={buffer.decode(errors='replace')!r}") + +def safe_kill_group(process_group): + if process_group is None or process_group <= 0 or process_group == pid: + return + try: + os.killpg(process_group, signal.SIGKILL) + except ProcessLookupError: + pass + +try: + read_until("RELAY_SHELL> ") + relay_command = " ".join(shlex.quote(value) for value in [relay, "--config", config, "run", "--agent", "codex"]) + # Keep a non-exec wrapper in Relay's shell job so suspension must target the whole group. + wrapped_command = f"{relay_command}; relay_status=$?; exit $relay_status" + os.write(master, f"/bin/sh -c {shlex.quote(wrapped_command)}\n".encode()) + + read_until("AGENT_READY:") + read_until("\n") + match = re.search(rb"AGENT_READY:(\d+):(\d+)", buffer) + assert match is not None, buffer + relay_pid = int(match.group(1)) + agent_group = int(match.group(2)) + relay_group = os.getpgid(relay_pid) + assert relay_group != agent_group, (relay_group, agent_group, buffer) + assert os.tcgetpgrp(master) == agent_group, (os.tcgetpgrp(master), agent_group, buffer) + + os.write(master, b"first-line\n") + read_until("AGENT_READ:first-line") + + os.write(master, b"\x1a") + read_until("RELAY_SHELL> ") + assert os.tcgetpgrp(master) == pid, (os.tcgetpgrp(master), pid, buffer) + + os.write(master, b"bg\n") + read_until("RELAY_SHELL> ") + time.sleep(0.1) + assert os.tcgetpgrp(master) == pid, (os.tcgetpgrp(master), pid, buffer) + os.write(master, b"echo BG_SHELL_OK\n") + read_until("BG_SHELL_OK") + read_until("RELAY_SHELL> ") + os.write(master, b"jobs\n") + read_until("Stopped") + read_until("RELAY_SHELL> ") + + os.write(master, b"fg\n") + os.write(master, b"second-line\n") + read_until("AGENT_READ:second-line") + + # Exercise `fg` while the background agent is still running instead of already stopped on a + # terminal read. Relay must notice that its owner group became foreground and transfer the + # terminal to the child without requiring a second `fg`. + os.write(master, b"delay-next-continue\n") + read_until("AGENT_DELAY_ARMED") + os.write(master, b"\x1a") + read_until("RELAY_SHELL> ") + os.write(master, b"bg\n") + read_until("RELAY_SHELL> ") + wait_until_present("AGENT_BG_DELAY") + assert os.tcgetpgrp(master) == pid, (os.tcgetpgrp(master), pid, buffer) + os.write(master, b"fg\n") + os.write(master, b"third-line\n") + read_until("AGENT_READ:third-line") + + os.write(master, b"\x03") + read_until("RELAY_SHELL> ") + os.write(master, b"exit\n") + os.close(master) + master_open = False + observed, status = os.waitpid(pid, 0) + reaped = True + assert observed == pid and (os.WIFEXITED(status) or os.WIFSIGNALED(status)), status +finally: + if not reaped: + safe_kill_group(agent_group) + safe_kill_group(relay_group) + try: + os.kill(pid, signal.SIGKILL) + except ProcessLookupError: + pass + if master_open: + os.close(master) + master_open = False + try: + os.waitpid(pid, 0) + except ChildProcessError: + pass + if master_open: + os.close(master) +"#, + ) + .unwrap(); + + let output = Command::new("python3") + .arg(&driver) + .arg(gateway_bin()) + .arg(&config) + .arg(&home) + .arg(&xdg) + .arg(&runtime) + .output() .unwrap(); - child.stdin.take().unwrap().write_all(b"").unwrap(); - let output = child.wait_with_output().unwrap(); - assert!(output.status.success()); - assert!(String::from_utf8_lossy(&output.stderr).contains("missing gateway URL")); + assert!( + output.status.success(), + "PTY driver failed:\nstdout={}\nstderr={}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); } +#[cfg(unix)] #[test] -fn cli_hook_forward_fails_closed_without_gateway_url() { - let mut child = Command::new(gateway_bin()) - .env_remove("NEMO_RELAY_GATEWAY_URL") - .args(["hook-forward", "codex", "--fail-closed"]) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .unwrap(); - child.stdin.take().unwrap().write_all(b"{}").unwrap(); - let output = child.wait_with_output().unwrap(); +fn cli_transparent_run_forwards_non_tty_termination_to_the_agent_tree() { + use std::os::unix::fs::PermissionsExt; - assert!(!output.status.success()); - assert!(String::from_utf8_lossy(&output.stderr).contains("missing gateway URL")); + let temp = tempfile::tempdir().unwrap(); + let home = temp.path().join("home"); + let xdg = temp.path().join("xdg"); + let runtime = temp.path().join("runtime"); + for directory in [&home, &xdg, &runtime] { + std::fs::create_dir_all(directory).unwrap(); + } + let agent = temp.path().join("non-tty-codex"); + std::fs::write( + &agent, + r#"#!/usr/bin/env python3 +import os +import subprocess +import sys +import time + +if sys.argv[1:] == ["--version"]: + print("codex-cli 0.143.0") + raise SystemExit(0) + +descendant = subprocess.Popen(["sleep", "30"]) +pid_path = os.environ["NEMO_RELAY_TEST_AGENT_PIDS"] +temporary_pid_path = f"{pid_path}.tmp-{os.getpid()}" +with open(temporary_pid_path, "w") as output: + output.write(f"{os.getpid()} {descendant.pid}") +os.replace(temporary_pid_path, pid_path) +while True: + time.sleep(1) +"#, + ) + .unwrap(); + std::fs::set_permissions(&agent, std::fs::Permissions::from_mode(0o755)).unwrap(); + let config = temp.path().join("config.toml"); + std::fs::write( + &config, + format!( + "[agents.codex]\ncommand = {}\n", + toml_basic_string(agent.to_string_lossy().as_ref()) + ), + ) + .unwrap(); + + for (signal, signal_name) in [ + (libc::SIGHUP, "SIGHUP"), + (libc::SIGINT, "SIGINT"), + (libc::SIGQUIT, "SIGQUIT"), + (libc::SIGTERM, "SIGTERM"), + ] { + let pids = temp.path().join(format!("agent-pids-{signal_name}")); + let mut relay = Command::new(gateway_bin()) + .args([ + "--config", + config.to_str().unwrap(), + "run", + "--agent", + "codex", + ]) + .env("HOME", &home) + .env("XDG_CONFIG_HOME", &xdg) + .env("XDG_RUNTIME_DIR", &runtime) + .env("NEMO_RELAY_TEST_AGENT_PIDS", &pids) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + + let deadline = Instant::now() + Duration::from_secs(10); + while !pids.is_file() { + if Instant::now() >= deadline { + // SAFETY: Relay's PID is live and owned by this test; SIGTERM exercises its + // registered cleanup path so any partially started descendants are reaped. + let _ = unsafe { libc::kill(relay.id() as i32, libc::SIGTERM) }; + let output = relay.wait_with_output().unwrap(); + panic!( + "agent PID file was not created for {signal_name}; stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + thread::sleep(Duration::from_millis(20)); + } + let supervised_pids = std::fs::read_to_string(&pids) + .unwrap() + .split_whitespace() + .map(|value| value.parse::().unwrap()) + .collect::>(); + assert_eq!(supervised_pids.len(), 2); + + // SAFETY: Relay's PID is live and owned by this test; each signal is caught and forwarded. + assert_eq!(unsafe { libc::kill(relay.id() as i32, signal) }, 0); + let status = wait_child(&mut relay); + assert!(!status.success()); + + for pid in supervised_pids { + let deadline = Instant::now() + Duration::from_secs(5); + loop { + // SAFETY: Signal zero is a read-only existence check for the recorded child PID. + let result = unsafe { libc::kill(pid, 0) }; + if result == -1 + && std::io::Error::last_os_error().raw_os_error() == Some(libc::ESRCH) + { + break; + } + assert!( + Instant::now() < deadline, + "coding-agent process {pid} survived Relay {signal_name}" + ); + thread::sleep(Duration::from_millis(20)); + } + } + } } #[test] @@ -1656,8 +3432,6 @@ fn cli_hook_forward_posts_payload_headers_and_prints_response() { .args([ "hook-forward", "codex", - "--gateway-url", - &server_url, "--profile", "coverage", "--session-metadata", @@ -1666,6 +3440,7 @@ fn cli_hook_forward_posts_payload_headers_and_prints_response() { "passthrough", "--fail-closed", ]) + .env("NEMO_RELAY_GATEWAY_URL", &server_url) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) @@ -1678,7 +3453,7 @@ fn cli_hook_forward_posts_payload_headers_and_prints_response() { .write_all(br#"{"hook_event_name":"sessionStart"}"#) .unwrap(); let output = child.wait_with_output().unwrap(); - let request = received.recv().unwrap(); + let request = received.recv_timeout(Duration::from_secs(2)).unwrap(); assert!(output.status.success()); assert_eq!( @@ -1692,16 +3467,289 @@ fn cli_hook_forward_posts_payload_headers_and_prints_response() { } #[test] -fn cli_hook_forward_hermes_shell_hook_returns_empty_object() { - let (server_url, received) = spawn_single_request_server(200, r#"{}"#); +fn cli_forward_only_unfenced_hook_posts_to_an_authenticated_gateway_without_recovery() { + let (output, requests) = run_forward_only_fake_bootstrap_listener(FakeBootstrapProof::Valid); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + assert!( + requests + .iter() + .any(|request| request.starts_with("POST /hooks/codex ")) + ); +} + +#[test] +fn cli_forward_only_unfenced_hook_rejects_a_foreign_listener_before_posting() { + let (output, requests) = run_forward_only_fake_bootstrap_listener(FakeBootstrapProof::Missing); + + assert!(!output.status.success()); + assert!(String::from_utf8_lossy(&output.stderr).contains("not a compatible")); + assert!(requests.iter().all(|request| !request.starts_with("POST "))); +} + +#[test] +fn cli_forward_only_never_reconnects_payload_after_authenticated_connection_closes() { + let temp = tempfile::tempdir().unwrap(); + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let key_path = temp + .path() + .join("xdg") + .join("nemo-relay") + .join("bootstrap") + .join("fingerprint-hmac.key"); + std::fs::create_dir_all(key_path.parent().unwrap()).unwrap(); + let key = [0x5a_u8; 32]; + std::fs::write(&key_path, key).unwrap(); + write_test_tls_identity(key_path.parent().unwrap()); + let stopped = Arc::new(AtomicBool::new(false)); + let requests = Arc::new(Mutex::new(Vec::new())); + let server_stopped = stopped.clone(); + let server_requests = requests.clone(); + let server = thread::spawn(move || -> Result { + listener.set_nonblocking(true).unwrap(); + let deadline = Instant::now() + Duration::from_secs(4); + let (mut stream, _) = loop { + if server_stopped.load(Ordering::Relaxed) { + return Err("hook-forward exited before authenticated tunnel".into()); + } + match listener.accept() { + Ok(connection) => break connection, + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + if Instant::now() >= deadline { + return Err("timed out waiting for authenticated tunnel".into()); + } + thread::sleep(Duration::from_millis(5)); + } + Err(error) => return Err(format!("bootstrap listener failed: {error}")), + } + }; + stream.set_nonblocking(false).unwrap(); + stream + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + let mut request = read_http_request(&mut stream); + server_requests.lock().unwrap().push(request.clone()); + if request.starts_with("GET /healthz ") { + let fingerprint = + bootstrap_request_header(&request, "x-nemo-relay-bootstrap-fingerprint") + .ok_or_else(|| "health probe omitted its fingerprint".to_string())?; + let nonce = bootstrap_request_header(&request, "x-nemo-relay-bootstrap-nonce") + .ok_or_else(|| "health probe omitted its nonce".to_string())?; + let proof = fake_bootstrap_proof(&key, fingerprint, nonce); + let body = format!( + r#"{{"status":"ok","service":"nemo-relay","version":"{}","bootstrap_protocol":2,"instance_id":"phase-health"}}"#, + env!("CARGO_PKG_VERSION") + ); + stream + .write_all( + format!( + "HTTP/1.1 200 OK\r\nX-NeMo-Relay-Bootstrap-Proof: {proof}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ) + .as_bytes(), + ) + .map_err(|error| format!("health response failed: {error}"))?; + drop(stream); + let next_deadline = Instant::now() + Duration::from_secs(4); + stream = loop { + match listener.accept() { + Ok((stream, _)) => break stream, + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + if Instant::now() >= next_deadline { + return Err("timed out waiting for authenticated tunnel".into()); + } + thread::sleep(Duration::from_millis(5)); + } + Err(error) => return Err(format!("bootstrap listener failed: {error}")), + } + }; + stream.set_nonblocking(false).unwrap(); + stream + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + request = read_http_request(&mut stream); + server_requests.lock().unwrap().push(request.clone()); + } + if !request.starts_with("GET /bootstrap/tunnel ") { + return Err(format!("unexpected tunnel request: {request}")); + } + let fingerprint = bootstrap_request_header(&request, "x-nemo-relay-bootstrap-fingerprint") + .ok_or_else(|| "tunnel omitted its fingerprint".to_string())?; + let nonce = bootstrap_request_header(&request, "x-nemo-relay-bootstrap-nonce") + .ok_or_else(|| "tunnel omitted its nonce".to_string())?; + let proof = fake_bootstrap_proof(&key, fingerprint, nonce); + stream + .write_all( + format!( + "HTTP/1.1 101 Switching Protocols\r\nX-NeMo-Relay-Bootstrap-Proof: {proof}\r\nConnection: upgrade\r\nUpgrade: nemo-relay-tls\r\nContent-Length: 0\r\n\r\n" + ) + .as_bytes(), + ) + .map_err(|error| format!("tunnel response failed: {error}"))?; + // Close after proving Relay identity but before completing TLS. A replacement listener on + // the same port must never receive the lifecycle payload on a fresh connection. + drop(stream); + let replacement_deadline = Instant::now() + Duration::from_secs(12); + while !server_stopped.load(Ordering::Relaxed) && Instant::now() < replacement_deadline { + match listener.accept() { + Ok((mut stream, _)) => { + stream.set_nonblocking(false).unwrap(); + stream + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + let request = read_http_request(&mut stream); + server_requests.lock().unwrap().push(request); + } + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + thread::sleep(Duration::from_millis(5)); + } + Err(error) => panic!("replacement listener failed: {error}"), + } + } + Ok(2) + }); + let mut child = Command::new(gateway_bin()) .args([ "hook-forward", - "hermes", + "codex", + "--gateway-url", + &format!("http://{address}"), + "--forward-only", + "--fail-closed", + ]) + .env("HOME", temp.path()) + .env("XDG_CONFIG_HOME", temp.path().join("xdg")) + .env("XDG_RUNTIME_DIR", temp.path().join("runtime")) + .env("TMPDIR", temp.path()) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + child + .stdin + .take() + .unwrap() + .write_all(b"{\"session_id\":\"replacement-race\"}") + .unwrap(); + let output = wait_child_with_output(child); + stopped.store(true, Ordering::Relaxed); + let authenticated_phases = server.join().unwrap().unwrap(); + let requests = Arc::try_unwrap(requests).unwrap().into_inner().unwrap(); + + assert_eq!(authenticated_phases, 2); + assert!(!output.status.success()); + assert_eq!( + requests + .iter() + .filter(|request| request.starts_with("POST ")) + .count(), + 0, + "replacement listener received a lifecycle payload: {requests:#?}" + ); +} + +#[test] +fn cli_transparent_run_suppresses_persistent_hooks_and_rejects_a_foreign_gateway() { + let persistent = Command::new(gateway_bin()) + .args([ + "hook-forward", + "codex", "--gateway-url", - &server_url, + "http://127.0.0.1:1", + "--forward-only", + "--fail-closed", + ]) + .env("NEMO_RELAY_TRANSPARENT_RUN", "1") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .and_then(|mut child| { + child + .stdin + .take() + .unwrap() + .write_all(b"{\"secret\":\"must-not-leave-stdin\"}")?; + child.wait_with_output() + }) + .unwrap(); + assert!(persistent.status.success()); + assert!(persistent.stdout.is_empty()); + assert!(persistent.stderr.is_empty()); + + let (server_url, received) = spawn_single_request_server(200, r#"{"continue":true}"#); + let owned = Command::new(gateway_bin()) + .args([ + "hook-forward", + "codex", + "--transparent-run", "--fail-closed", ]) + .env("NEMO_RELAY_TRANSPARENT_RUN", "1") + .env("NEMO_RELAY_GATEWAY_URL", &server_url) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .and_then(|mut child| { + child + .stdin + .take() + .unwrap() + .write_all(b"{\"session_id\":\"owned\"}")?; + child.wait_with_output() + }) + .unwrap(); + assert!(!owned.status.success()); + assert!( + String::from_utf8_lossy(&owned.stderr).contains("verified hook forward failed"), + "{}", + String::from_utf8_lossy(&owned.stderr) + ); + let request = received.recv_timeout(Duration::from_secs(2)).unwrap(); + assert!(request.starts_with("GET /bootstrap/tunnel ")); + assert!(!request.contains(r#"{"session_id":"owned"}"#)); +} + +#[test] +fn cli_hook_forward_bypasses_ambient_proxies_for_loopback_delivery() { + let (server_url, received) = spawn_single_request_server(200, r#"{"continue":true}"#); + let mut child = Command::new(gateway_bin()) + .args(["hook-forward", "codex", "--fail-closed"]) + .env("NEMO_RELAY_GATEWAY_URL", &server_url) + .env("HTTP_PROXY", "http://127.0.0.1:1") + .env("HTTPS_PROXY", "http://127.0.0.1:1") + .env("ALL_PROXY", "http://127.0.0.1:1") + .env_remove("NO_PROXY") + .env_remove("no_proxy") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + child.stdin.take().unwrap().write_all(b"{}").unwrap(); + let output = child.wait_with_output().unwrap(); + + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + assert!(received.recv_timeout(Duration::from_secs(2)).is_ok()); +} + +#[test] +fn cli_hook_forward_hermes_shell_hook_returns_empty_object() { + let (server_url, received) = spawn_single_request_server(200, r#"{}"#); + let mut child = Command::new(gateway_bin()) + .args(["hook-forward", "hermes", "--fail-closed"]) + .env("NEMO_RELAY_GATEWAY_URL", &server_url) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) @@ -1714,7 +3762,7 @@ fn cli_hook_forward_hermes_shell_hook_returns_empty_object() { .write_all(br#"{"session_id":"smoke-hermes","hook_event_name":"on_session_start"}"#) .unwrap(); let output = child.wait_with_output().unwrap(); - let request = received.recv().unwrap(); + let request = received.recv_timeout(Duration::from_secs(2)).unwrap(); assert!(output.status.success()); assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), r#"{}"#); @@ -1728,13 +3776,8 @@ fn cli_hook_forward_hermes_shell_hook_returns_empty_object() { fn cli_hook_forward_reports_http_failure_when_fail_closed() { let (server_url, received) = spawn_single_request_server(503, "unavailable"); let mut child = Command::new(gateway_bin()) - .args([ - "hook-forward", - "hermes", - "--gateway-url", - &server_url, - "--fail-closed", - ]) + .args(["hook-forward", "hermes", "--fail-closed"]) + .env("NEMO_RELAY_GATEWAY_URL", &server_url) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) @@ -1742,7 +3785,7 @@ fn cli_hook_forward_reports_http_failure_when_fail_closed() { .unwrap(); child.stdin.take().unwrap().write_all(b"{}").unwrap(); let output = child.wait_with_output().unwrap(); - let request = received.recv().unwrap(); + let request = received.recv_timeout(Duration::from_secs(2)).unwrap(); assert!(!output.status.success()); assert!(request.contains("POST /hooks/hermes HTTP/1.1")); @@ -1756,7 +3799,8 @@ fn cli_hook_forward_exits_two_for_guardrail_rejection() { r#"{"error":{"message":"guardrail rejected: blocked by policy","type":"nemo_relay_guardrail_rejected","reason":"blocked by policy"}}"#, ); let mut child = Command::new(gateway_bin()) - .args(["hook-forward", "codex", "--gateway-url", &server_url]) + .args(["hook-forward", "codex"]) + .env("NEMO_RELAY_GATEWAY_URL", &server_url) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) @@ -1764,7 +3808,7 @@ fn cli_hook_forward_exits_two_for_guardrail_rejection() { .unwrap(); child.stdin.take().unwrap().write_all(b"{}").unwrap(); let output = child.wait_with_output().unwrap(); - let request = received.recv().unwrap(); + let request = received.recv_timeout(Duration::from_secs(2)).unwrap(); assert_eq!(output.status.code(), Some(2)); assert!(request.contains("POST /hooks/codex HTTP/1.1")); @@ -1774,13 +3818,8 @@ fn cli_hook_forward_exits_two_for_guardrail_rejection() { #[test] fn cli_hook_forward_reports_transport_failure_when_fail_closed() { let mut child = Command::new(gateway_bin()) - .args([ - "hook-forward", - "codex", - "--gateway-url", - "http://127.0.0.1:1", - "--fail-closed", - ]) + .args(["hook-forward", "codex", "--fail-closed"]) + .env("NEMO_RELAY_GATEWAY_URL", "http://127.0.0.1:1") .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) @@ -1793,10 +3832,41 @@ fn cli_hook_forward_reports_transport_failure_when_fail_closed() { assert!(String::from_utf8_lossy(&output.stderr).contains("hook forward failed")); } +#[test] +fn cli_hook_forward_bounds_responses_under_both_failure_policies() { + const MAX_HOOK_RESPONSE_BYTES: usize = 1024 * 1024; + for fail_closed in [false, true] { + let (server_url, received) = + spawn_single_request_server(200, "x".repeat(MAX_HOOK_RESPONSE_BYTES + 1)); + let mut command = Command::new(gateway_bin()); + command + .args(["hook-forward", "hermes"]) + .env("NEMO_RELAY_GATEWAY_URL", &server_url) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + if fail_closed { + command.arg("--fail-closed"); + } + let mut child = command.spawn().unwrap(); + child.stdin.take().unwrap().write_all(b"{}").unwrap(); + let output = child.wait_with_output().unwrap(); + + assert_eq!(output.status.success(), !fail_closed); + assert!(output.stdout.is_empty()); + assert!( + String::from_utf8_lossy(&output.stderr) + .contains("hook forward response exceeds the 1048576-byte limit") + ); + assert!(received.recv_timeout(Duration::from_secs(2)).is_ok()); + } +} + fn spawn_single_request_server( status: u16, - body: &'static str, + body: impl Into, ) -> (String, mpsc::Receiver) { + let body = body.into(); let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let address = listener.local_addr().unwrap(); let (sender, receiver) = mpsc::channel(); @@ -1813,7 +3883,7 @@ fn spawn_single_request_server( (format!("http://{address}"), receiver) } -fn read_http_request(stream: &mut std::net::TcpStream) -> String { +fn read_http_request(stream: &mut impl Read) -> String { let mut buffer = Vec::new(); let mut scratch = [0; 1024]; loop { diff --git a/crates/cli/tests/coverage/adapters_tests.rs b/crates/cli/tests/coverage/agents/adapters_tests.rs similarity index 99% rename from crates/cli/tests/coverage/adapters_tests.rs rename to crates/cli/tests/coverage/agents/adapters_tests.rs index 5a1087d7d..65e0b73b6 100644 --- a/crates/cli/tests/coverage/adapters_tests.rs +++ b/crates/cli/tests/coverage/agents/adapters_tests.rs @@ -5,7 +5,7 @@ use axum::http::HeaderMap; use serde_json::json; use super::*; -use crate::adapters::{claude_code, codex, hermes}; +use crate::agents::shared::adapters::{claude_code, codex, hermes}; #[test] fn maps_claude_canonical_tool_payload() { diff --git a/crates/cli/tests/coverage/alignment_claude_code_tests.rs b/crates/cli/tests/coverage/agents/alignment_claude_code_tests.rs similarity index 100% rename from crates/cli/tests/coverage/alignment_claude_code_tests.rs rename to crates/cli/tests/coverage/agents/alignment_claude_code_tests.rs diff --git a/crates/cli/tests/coverage/alignment_codex_tests.rs b/crates/cli/tests/coverage/agents/alignment_codex_tests.rs similarity index 87% rename from crates/cli/tests/coverage/alignment_codex_tests.rs rename to crates/cli/tests/coverage/agents/alignment_codex_tests.rs index 4c1e6761d..93183944c 100644 --- a/crates/cli/tests/coverage/alignment_codex_tests.rs +++ b/crates/cli/tests/coverage/agents/alignment_codex_tests.rs @@ -29,37 +29,75 @@ fn thread_spawn(parent_thread_id: &str) -> Value { } #[test] -fn prompt_cache_session_id_requires_codex_responses_metadata() { +fn responses_session_id_prefers_shared_id_and_keeps_prompt_cache_fallback() { let body = json!({ "prompt_cache_key": "thread-1", - "client_metadata": { "x-codex-installation-id": "install-1" } + "client_metadata": { + "x-codex-installation-id": "install-1", + "session_id": "session-1" + } }); assert_eq!( - prompt_cache_session_id(&body, GatewayRouteKind::OpenAiResponses).as_deref(), - Some("thread-1") + responses_session_id(&body, GatewayRouteKind::OpenAiResponses).as_deref(), + Some("session-1") ); assert_eq!( - prompt_cache_session_id(&body, GatewayRouteKind::OpenAiChatCompletions), + responses_session_id(&body, GatewayRouteKind::OpenAiChatCompletions), None ); assert_eq!( - prompt_cache_session_id( + responses_session_id( &json!({ "prompt_cache_key": "plain-cache" }), GatewayRouteKind::OpenAiResponses, ), None ); assert_eq!( - prompt_cache_session_id( + responses_session_id( &json!({ - "prompt_cache_key": "", + "prompt_cache_key": "legacy-thread", "client_metadata": { "x-codex-installation-id": "install-1" } }), GatewayRouteKind::OpenAiResponses, - ), + ) + .as_deref(), + Some("legacy-thread") + ); +} + +#[test] +fn responses_subagent_id_accepts_only_codex_collab_spawn_metadata() { + let body = json!({ + "client_metadata": { + "x-codex-installation-id": "install-1", + "x-openai-subagent": "collab_spawn", + "thread_id": "child-thread" + } + }); + + assert_eq!( + responses_subagent_id(&body, GatewayRouteKind::OpenAiResponses).as_deref(), + Some("child-thread") + ); + assert_eq!( + responses_subagent_id(&body, GatewayRouteKind::OpenAiChatCompletions), None ); + + for kind in ["review", "compact", "memory"] { + let body = json!({ + "client_metadata": { + "x-codex-installation-id": "install-1", + "x-openai-subagent": kind, + "thread_id": "internal-thread" + } + }); + assert_eq!( + responses_subagent_id(&body, GatewayRouteKind::OpenAiResponses), + None + ); + } } #[test] diff --git a/crates/cli/tests/coverage/alignment_tests.rs b/crates/cli/tests/coverage/agents/alignment_tests.rs similarity index 99% rename from crates/cli/tests/coverage/alignment_tests.rs rename to crates/cli/tests/coverage/agents/alignment_tests.rs index 4d0cf4b5b..55a1c9151 100644 --- a/crates/cli/tests/coverage/alignment_tests.rs +++ b/crates/cli/tests/coverage/agents/alignment_tests.rs @@ -6,7 +6,7 @@ use nemo_relay::api::llm::LlmRequest; use serde_json::Map; use super::*; -use crate::model::{LlmEvent, LlmHintEvent}; +use crate::events::{LlmEvent, LlmHintEvent}; fn session_event(session_id: &str, event_name: &str) -> SessionEvent { SessionEvent { @@ -250,7 +250,10 @@ fn gateway_subagent_and_identifier_helpers_respect_header_precedence() { "object": { "id": { "nested": true } } }); - assert_eq!(gateway_subagent_id(&headers).as_deref(), Some("worker-1")); + assert_eq!( + gateway_subagent_id(&headers, &body, GatewayRouteKind::OpenAiResponses).as_deref(), + Some("worker-1") + ); assert_eq!( gateway_identifier( &headers, diff --git a/crates/cli/tests/coverage/agents/coding_agent_tests.rs b/crates/cli/tests/coverage/agents/coding_agent_tests.rs new file mode 100644 index 000000000..8447d0273 --- /dev/null +++ b/crates/cli/tests/coverage/agents/coding_agent_tests.rs @@ -0,0 +1,105 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use super::*; + +#[test] +fn agent_descriptors_are_complete_and_unique() { + let arguments = CodingAgent::ALL.map(CodingAgent::as_arg); + let install_arguments = CodingAgent::ALL.map(CodingAgent::install_arg); + let executables = CodingAgent::ALL.map(CodingAgent::executable); + let hook_paths = CodingAgent::ALL.map(CodingAgent::hook_path); + + assert_eq!(arguments, ["claude", "codex", "hermes"]); + assert_eq!(install_arguments, ["claude-code", "codex", "hermes"]); + assert_eq!(executables, ["claude", "codex", "hermes"]); + assert_eq!( + hook_paths, + ["/hooks/claude-code", "/hooks/codex", "/hooks/hermes"] + ); + assert_eq!(CodingAgent::ClaudeCode.label(), "Claude Code"); + assert_eq!(CodingAgent::Codex.label(), "Codex"); + assert_eq!(CodingAgent::Hermes.label(), "Hermes Agent"); + assert_eq!(CodingAgent::ClaudeCode.hook_events().len(), 14); + assert_eq!(CodingAgent::Codex.hook_events().len(), 10); + assert_eq!(CodingAgent::Hermes.hook_events().len(), 13); + assert!(!CodingAgent::ClaudeCode.uses_direct_hook_entries()); + assert!(!CodingAgent::Codex.uses_direct_hook_entries()); + assert!(CodingAgent::Hermes.uses_direct_hook_entries()); + for agent in CodingAgent::ALL { + let events = agent.hook_events(); + assert!(events.iter().all(|event| !event.is_empty())); + assert_eq!( + events + .iter() + .collect::>() + .len(), + events.len(), + "{agent:?} declares duplicate lifecycle events" + ); + } +} + +#[test] +fn centralized_minimum_versions_accept_stable_boundaries() { + let cases = [ + (CodingAgent::ClaudeCode, "2.1.121 (Claude Code)"), + (CodingAgent::Codex, "codex-cli 0.143.0"), + (CodingAgent::Hermes, "Hermes Agent v0.18.2 (2026.7.7.2)"), + ]; + + for (agent, output) in cases { + assert_eq!( + agent.validate_version_output(output).unwrap(), + agent.minimum_version() + ); + } +} + +#[test] +fn centralized_minimum_versions_reject_old_prerelease_and_malformed_output() { + let cases = [ + (CodingAgent::ClaudeCode, "2.1.120 (Claude Code)"), + (CodingAgent::ClaudeCode, "2.1.121-beta.1 (Claude Code)"), + (CodingAgent::ClaudeCode, "2.1.121 (Other Agent)"), + (CodingAgent::Codex, "codex-cli 0.142.9"), + (CodingAgent::Codex, "codex-cli 0.143.0-alpha.1"), + (CodingAgent::Hermes, "Hermes Agent v0.18.1"), + (CodingAgent::Hermes, "Hermes Agent v0.18.2-rc.1"), + ]; + + for (agent, output) in cases { + assert!( + agent.validate_version_output(output).is_err(), + "{agent:?}: {output}" + ); + } + for agent in CodingAgent::ALL { + assert!(agent.validate_version_output("unknown version").is_err()); + assert!(agent.validate_version_output("").is_err()); + } +} + +#[test] +fn agent_inference_accepts_supported_binary_aliases() { + assert_eq!( + CodingAgent::infer("/opt/bin/claude"), + Some(CodingAgent::ClaudeCode) + ); + assert_eq!( + CodingAgent::infer("claude-code"), + Some(CodingAgent::ClaudeCode) + ); + assert_eq!(CodingAgent::infer("codex"), Some(CodingAgent::Codex)); + assert_eq!(CodingAgent::infer("CODEX.EXE"), Some(CodingAgent::Codex)); + assert_eq!( + CodingAgent::infer(r"C:\\tools\\codex.cmd"), + Some(CodingAgent::Codex) + ); + assert_eq!(CodingAgent::infer("@openai/codex"), None); + assert_eq!( + CodingAgent::infer("hermes-agent"), + Some(CodingAgent::Hermes) + ); + assert_eq!(CodingAgent::infer("unknown"), None); +} diff --git a/crates/cli/tests/coverage/agents/hermes_tests.rs b/crates/cli/tests/coverage/agents/hermes_tests.rs new file mode 100644 index 000000000..2d8e59b3f --- /dev/null +++ b/crates/cli/tests/coverage/agents/hermes_tests.rs @@ -0,0 +1,1569 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use std::cell::Cell; +use std::ffi::OsString; +use std::path::Path; +use std::sync::MutexGuard; +use std::time::{Duration, UNIX_EPOCH}; + +use serde_json::{Value, json}; + +use super::*; +use crate::agents::CodingAgent; + +const TEST_GENERATION_TOKEN: &str = "test-generation"; + +fn relay_binary(root: &Path) -> PathBuf { + let path = root.join("NeMo Relay's bin").join("nemo-relay"); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, b"relay").unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + path +} + +fn paths(root: &Path) -> PersistentPaths { + PersistentPaths::for_config(root.join("config.yaml")).unwrap() +} + +fn yaml(path: &Path) -> Value { + serde_yaml::from_str(&std::fs::read_to_string(path).unwrap()).unwrap() +} + +fn json_file(path: &Path) -> Value { + serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap() +} + +struct XdgConfigHomeScope { + _guard: MutexGuard<'static, ()>, + previous: Option, +} + +impl XdgConfigHomeScope { + fn enter(path: &Path) -> Self { + let guard = crate::test_support::ENV_TEST_LOCK + .lock() + .unwrap_or_else(|error| error.into_inner()); + let previous = std::env::var_os("XDG_CONFIG_HOME"); + // SAFETY: This scope holds the process-wide environment mutex. + unsafe { std::env::set_var("XDG_CONFIG_HOME", path) }; + Self { + _guard: guard, + previous, + } + } +} + +impl Drop for XdgConfigHomeScope { + fn drop(&mut self) { + // SAFETY: This restores the process environment while the mutex is still held. + unsafe { + match self.previous.take() { + Some(value) => std::env::set_var("XDG_CONFIG_HOME", value), + None => std::env::remove_var("XDG_CONFIG_HOME"), + } + } + } +} + +#[test] +fn user_config_path_uses_hermes_home_or_platform_home() { + let default_home = Path::new("/users/relay"); + assert_eq!( + user_config_path_with_override(default_home, None), + default_home.join(".hermes/config.yaml") + ); + assert_eq!( + user_config_path_with_override(default_home, Some("/profiles/hermes".into())), + Path::new("/profiles/hermes/config.yaml") + ); + assert_eq!( + user_config_path_with_override(default_home, Some("".into())), + default_home.join(".hermes/config.yaml") + ); +} + +#[test] +fn install_lock_serializes_concurrent_hermes_config_updates() { + let temp = tempfile::tempdir().unwrap(); + let config = temp.path().join("config.yaml"); + let _first = acquire_install_lock(&config, Duration::from_millis(10)).unwrap(); + + let error = acquire_install_lock(&config, Duration::ZERO).unwrap_err(); + + assert!( + error.contains("another Hermes integration update"), + "{error}" + ); +} + +#[test] +fn install_uses_the_native_hermes_allowlist_lock() { + let temp = tempfile::tempdir().unwrap(); + let allowlist = temp.path().join("shell-hooks-allowlist.json"); + let _first = acquire_allowlist_lock(&allowlist, Duration::from_millis(10)).unwrap(); + + let error = acquire_allowlist_lock(&allowlist, Duration::ZERO).unwrap_err(); + + assert!(error.contains("shell-hook approval update"), "{error}"); + assert!(temp.path().join("shell-hooks-allowlist.json.lock").exists()); +} + +#[test] +fn hook_command_round_trips_paths_and_platform_metacharacters() { + let relay = Path::new("/tmp/NeMo $Relay`test'/bin/nemo-relay"); + let generation = Path::new("/tmp/generation"); + assert_eq!( + persistent_hook_command_for_platform(relay, generation, TEST_GENERATION_TOKEN, false), + "'/tmp/NeMo $Relay`test'\\''/bin/nemo-relay' hook-forward hermes --gateway-url http://127.0.0.1:47632 --generation-file /tmp/generation --generation-token test-generation" + ); + assert_eq!( + crate::hooks::decode_windows_hook_command(&persistent_hook_command_for_platform( + Path::new(r"C:\Program Files\NeMo 100%\bin\nemo-relay.exe"), + Path::new(r"C:\Temp\generation"), + TEST_GENERATION_TOKEN, + true, + )) + .unwrap(), + vec![ + r"C:\Program Files\NeMo 100%\bin\nemo-relay.exe", + "hook-forward", + "hermes", + "--gateway-url", + crate::bootstrap::DEFAULT_URL, + "--generation-file", + r"C:\Temp\generation", + "--generation-token", + TEST_GENERATION_TOKEN, + ] + ); + assert_eq!( + crate::hooks::transparent_hook_forward_command_for_platform( + relay, + CodingAgent::Hermes, + "http://127.0.0.1:1234", + false, + ), + "'/tmp/NeMo $Relay`test'\\''/bin/nemo-relay' hook-forward hermes --gateway-url http://127.0.0.1:1234 --transparent-run" + ); + let encoded = persistent_hook_command_for_platform( + Path::new(r"C:\Program Files\NeMo 100%\bin\nemo-relay.exe"), + Path::new(r"C:\Temp\generation"), + TEST_GENERATION_TOKEN, + true, + ); + assert!(is_persistent_relay_hook_command(&encoded)); + let encoded_codex = crate::hooks::persistent_hook_forward_command_for_platform( + Path::new(r"C:\Program Files\NeMo 100%\bin\nemo-relay.exe"), + CodingAgent::Codex, + Path::new(r"C:\Temp\generation"), + TEST_GENERATION_TOKEN, + true, + ); + assert_ne!(encoded, encoded_codex); + assert!(!is_persistent_relay_hook_command(&encoded_codex)); +} + +#[test] +fn forwarded_environment_includes_static_dynamic_and_config_referenced_names() { + let environment = vec![ + "AWS_REGION".into(), + "NEMO_RELAY_CUSTOM".into(), + "NEMO_RELAY_WORKER_TOKEN".into(), + "UNRELATED_SECRET".into(), + ]; + let config = json!({ + "header_env": {"Authorization": "CUSTOM_EXPORT_TOKEN"}, + "secret_access_key_var": "AWS_PRIVATE_SECRET", + "session_token_var": "NEMO_RELAY_WORKER_TOKEN" + }); + let names = forwarded_environment_names(&environment, Some(&config)); + + assert!(names.contains(&"ANTHROPIC_API_KEY".into())); + assert!(names.contains(&"OPENAI_API_KEY".into())); + assert!(names.contains(&"AWS_REGION".into())); + assert!(names.contains(&"NEMO_RELAY_CUSTOM".into())); + assert!(names.contains(&"CUSTOM_EXPORT_TOKEN".into())); + assert!(names.contains(&"AWS_PRIVATE_SECRET".into())); + assert!(names.contains(&"AWS_PROFILE".into())); + assert!(names.contains(&"OTEL_EXPORTER_OTLP_ENDPOINT".into())); + assert!(!names.contains(&"NEMO_RELAY_WORKER_TOKEN".into())); + assert!(!names.contains(&"UNRELATED_SECRET".into())); +} + +#[test] +fn persistent_config_migrates_owned_state_and_preserves_unrelated_config() { + let temp = tempfile::tempdir().unwrap(); + let relay = relay_binary(temp.path()); + let generation = temp.path().join(GENERATION_FILE_NAME); + let command = persistent_hook_command(&relay, &generation, TEST_GENERATION_TOKEN).unwrap(); + let legacy_command = format!("{} hook-forward hermes", relay.display()); + let mut legacy_hooks = serde_json::Map::new(); + for event in CodingAgent::Hermes.hook_events() { + legacy_hooks.insert(event.to_string(), json!([{"command": legacy_command}])); + } + legacy_hooks.insert( + "on_session_start".into(), + json!([ + {"command": "custom-hook", "timeout": 9}, + {"command": legacy_command, "timeout": 30} + ]), + ); + legacy_hooks.insert("custom_event".into(), json!([{"command": "keep-custom"}])); + let existing = serde_yaml::to_string(&json!({ + "model": "keep-me", + "mcp_servers": { + "filesystem": {"command": "fs-mcp"}, + MCP_SERVER_NAME: {"command": relay, "args": ["mcp", "--agent", "hermes"]} + }, + "hooks": legacy_hooks + })) + .unwrap(); + let merged = persistent_config( + Some(&existing), + &relay, + &command, + &generation, + TEST_GENERATION_TOKEN, + &["AWS_REGION".into()], + ) + .unwrap(); + + assert_eq!(merged["model"], json!("keep-me")); + assert_eq!( + merged["mcp_servers"]["filesystem"]["command"], + json!("fs-mcp") + ); + assert_eq!( + merged["mcp_servers"][MCP_SERVER_NAME], + expected_mcp_server( + &relay, + &generation, + TEST_GENERATION_TOKEN, + &["AWS_REGION".into()] + ) + ); + assert_eq!( + merged["mcp_servers"][MCP_SERVER_NAME]["env"]["AWS_REGION"], + json!("${AWS_REGION}") + ); + assert_eq!( + merged["hooks"]["on_session_start"] + .as_array() + .unwrap() + .len(), + 2 + ); + assert_eq!( + merged["hooks"]["on_session_start"][0]["command"], + json!("custom-hook") + ); + assert_eq!( + merged["hooks"]["on_session_start"][1]["command"], + json!(command) + ); + assert_eq!( + merged["hooks"]["custom_event"][0]["command"], + json!("keep-custom") + ); + for event in CodingAgent::Hermes.hook_events() { + let groups = merged["hooks"][event].as_array().unwrap(); + assert_eq!( + groups + .iter() + .filter(|group| group["command"] == json!(command)) + .count(), + 1, + "event {event}" + ); + } +} + +#[test] +fn persistent_config_rejects_a_foreign_server_with_the_reserved_name() { + let temp = tempfile::tempdir().unwrap(); + let relay = relay_binary(temp.path()); + let generation = temp.path().join(GENERATION_FILE_NAME); + let command = persistent_hook_command(&relay, &generation, TEST_GENERATION_TOKEN).unwrap(); + let existing = r#" +model: keep-me +mcp_servers: + nemo-relay: + command: foreign-mcp + args: [serve] +"#; + + let error = persistent_config( + Some(existing), + &relay, + &command, + &generation, + TEST_GENERATION_TOKEN, + &[], + ) + .unwrap_err() + .to_string(); + + assert!(error.contains("not managed by Relay"), "{error}"); + assert!(error.contains("rename or remove"), "{error}"); +} + +#[test] +fn manual_same_named_mcp_and_hooks_are_never_claimed() { + let temp = tempfile::tempdir().unwrap(); + let relay = relay_binary(temp.path()); + let generation = temp.path().join(GENERATION_FILE_NAME); + let command = persistent_hook_command(&relay, &generation, TEST_GENERATION_TOKEN).unwrap(); + let manual = serde_yaml::to_string(&json!({ + "mcp_servers": { + MCP_SERVER_NAME: {"command": relay, "args": ["mcp"], "env": {"CUSTOM": "keep"}} + }, + "hooks": { + "on_session_start": [{"command": format!("{} hook-forward hermes", relay.display())}] + } + })) + .unwrap(); + + let error = persistent_config( + Some(&manual), + &relay, + &command, + &generation, + TEST_GENERATION_TOKEN, + &[], + ) + .unwrap_err() + .to_string(); + assert!(error.contains("not managed by Relay"), "{error}"); + + let paths = paths(&temp.path().join("hermes")); + std::fs::create_dir_all(paths.config.parent().unwrap()).unwrap(); + std::fs::write(&paths.config, &manual).unwrap(); + std::fs::write( + &paths.allowlist, + serde_json::to_vec(&json!({"approvals": [{ + "event": "on_session_start", + "command": format!("{} hook-forward hermes", relay.display()) + }]})) + .unwrap(), + ) + .unwrap(); + std::fs::write(&paths.generation, "orphaned-relay-state\n").unwrap(); + + uninstall_persistent_with(paths.clone(), atomic_write).unwrap(); + assert_eq!(std::fs::read_to_string(&paths.config).unwrap(), manual); + assert_eq!( + json_file(&paths.allowlist)["approvals"] + .as_array() + .unwrap() + .len(), + 1 + ); +} + +#[test] +fn modern_mcp_generation_proves_ownership_independently_of_hook_completeness() { + let temp = tempfile::tempdir().unwrap(); + let relay = relay_binary(temp.path()); + let generation = temp.path().join(GENERATION_FILE_NAME); + let mut root = persistent_config( + None, + &relay, + &persistent_hook_command(&relay, &generation, "hook-token").unwrap(), + &generation, + "mcp-token", + &[], + ) + .unwrap(); + assert_eq!( + owned_install_command(&root, &relay, Some(&generation)) + .unwrap() + .as_deref(), + Some( + persistent_hook_command(&relay, &generation, "mcp-token") + .unwrap() + .as_str() + ) + ); + + root["mcp_servers"][MCP_SERVER_NAME]["command"] = json!(temp.path().join("other/nemo-relay")); + assert!( + owned_install_command(&root, &relay, Some(&generation)) + .unwrap() + .is_none() + ); +} + +#[test] +fn foreign_reserved_server_aborts_install_before_any_file_changes() { + let temp = tempfile::tempdir().unwrap(); + let relay = relay_binary(temp.path()); + let paths = paths(&temp.path().join("hermes")); + std::fs::create_dir_all(paths.config.parent().unwrap()).unwrap(); + let config = + b"# preserve\nmcp_servers:\n nemo-relay:\n command: foreign-mcp\n args: [serve]\n"; + let allowlist = b"{\"approvals\":[{\"event\":\"custom\",\"command\":\"custom-hook\"}]}\n"; + std::fs::write(&paths.config, config).unwrap(); + std::fs::write(&paths.allowlist, allowlist).unwrap(); + + let error = install_persistent_with(paths.clone(), &relay, &[], None, UNIX_EPOCH, atomic_write) + .unwrap_err() + .to_string(); + + assert!(error.contains("not managed by Relay"), "{error}"); + assert_eq!(std::fs::read(&paths.config).unwrap(), config); + assert_eq!(std::fs::read(&paths.allowlist).unwrap(), allowlist); + assert!(!paths.generation.exists()); +} + +#[test] +fn trusted_hooks_migrates_only_relay_approvals_and_records_every_event() { + let temp = tempfile::tempdir().unwrap(); + let relay = relay_binary(temp.path()); + let generation = temp.path().join(GENERATION_FILE_NAME); + let command = persistent_hook_command(&relay, &generation, TEST_GENERATION_TOKEN).unwrap(); + let existing = json!({ + "schema": 7, + "approvals": [ + {"event": "custom", "command": "custom-hook", "approved_at": "keep"}, + {"event": "on_session_start", "command": "nemo-relay hook-forward hermes"}, + {"event": "on_session_end", "command": "/old/nemo-relay plugin-shim hook hermes"} + ] + }); + let now = UNIX_EPOCH + Duration::from_secs(1_700_000_000); + let merged = trusted_hooks( + Some(&serde_json::to_string(&existing).unwrap()), + Some("nemo-relay hook-forward hermes"), + &command, + &relay, + now, + ) + .unwrap(); + let approvals = merged["approvals"].as_array().unwrap(); + + assert_eq!(merged["schema"], json!(7)); + assert!( + approvals + .iter() + .any(|entry| entry["command"] == json!("custom-hook")) + ); + assert_eq!(approvals.len(), CodingAgent::Hermes.hook_events().len() + 2); + for event in CodingAgent::Hermes.hook_events() { + let entries = approvals + .iter() + .filter(|entry| entry["event"] == json!(event) && entry["command"] == json!(command)) + .collect::>(); + assert_eq!(entries.len(), 1, "event {event}"); + assert_eq!( + entries[0]["approved_at"], + json!("2023-11-14T22:13:20.000000Z") + ); + assert!(entries[0].get("script_mtime_at_approval").is_some()); + } +} + +#[test] +fn verification_rejects_relay_handlers_and_approvals_on_unexpected_events() { + let temp = tempfile::tempdir().unwrap(); + let relay = relay_binary(temp.path()); + let generation = temp.path().join(GENERATION_FILE_NAME); + let command = persistent_hook_command(&relay, &generation, TEST_GENERATION_TOKEN).unwrap(); + let mut config = persistent_config( + None, + &relay, + &command, + &generation, + TEST_GENERATION_TOKEN, + &[], + ) + .unwrap(); + config["hooks"]["unexpected_event"] = json!([{"command": command, "timeout": 30}]); + let error = verify_hook_definitions(&config, &command).unwrap_err(); + assert!(error.contains("unexpected Relay hook")); + let mut malformed = persistent_config( + None, + &relay, + &command, + &generation, + TEST_GENERATION_TOKEN, + &[], + ) + .unwrap(); + malformed["hooks"]["unexpected_event"] = json!({"command": command}); + let error = verify_hook_definitions(&malformed, &command).unwrap_err(); + assert!(error.contains("must be an array")); + + let mut allowlist = trusted_hooks(None, None, &command, &relay, UNIX_EPOCH).unwrap(); + allowlist["approvals"].as_array_mut().unwrap().push(json!({ + "event": "unexpected_event", + "command": command, + "approved_at": "1970-01-01T00:00:00.000000Z" + })); + let path = temp.path().join("shell-hooks-allowlist.json"); + std::fs::write(&path, serde_json::to_vec(&allowlist).unwrap()).unwrap(); + let error = verify_trust(&path, &command).unwrap_err(); + assert!(error.contains("unexpected Relay hook approval")); + + let mut missing_event = trusted_hooks(None, None, &command, &relay, UNIX_EPOCH).unwrap(); + missing_event["approvals"] + .as_array_mut() + .unwrap() + .push(json!({ + "command": command, + "approved_at": "1970-01-01T00:00:00.000000Z" + })); + std::fs::write(&path, serde_json::to_vec(&missing_event).unwrap()).unwrap(); + let error = verify_trust(&path, &command).unwrap_err(); + assert!(error.contains("missing its event")); +} + +#[test] +fn hermes_structure_and_trust_validation_cover_exact_failure_shapes() { + let temp = tempfile::tempdir().unwrap(); + let relay = relay_binary(temp.path()); + let generation = temp.path().join(GENERATION_FILE_NAME); + let command = persistent_hook_command(&relay, &generation, TEST_GENERATION_TOKEN).unwrap(); + + let error = trusted_hooks( + Some(r#"{"approvals": {}}"#), + None, + &command, + &relay, + UNIX_EPOCH, + ) + .unwrap_err() + .to_string(); + assert!(error.contains("approvals must be an array"), "{error}"); + + let error = parse_json_object(Some("[]"), "test allowlist") + .unwrap_err() + .to_string(); + assert!(error.contains("must contain a JSON object"), "{error}"); + + let mut malformed_hooks = json!({"hooks": {"on_session_start": {}}}); + let error = strip_owned_hooks(&mut malformed_hooks, Some(&command)) + .unwrap_err() + .to_string(); + assert!( + error.contains("on_session_start hooks must be an array"), + "{error}" + ); + + let error = parse_yaml_object(Some("[]"), "test config") + .unwrap_err() + .to_string(); + assert!(error.contains("must contain an object"), "{error}"); + let path = temp.path().join("shell-hooks-allowlist.json"); + let mut missing = trusted_hooks(None, None, &command, &relay, UNIX_EPOCH).unwrap(); + missing["approvals"].as_array_mut().unwrap().remove(0); + std::fs::write(&path, serde_json::to_vec(&missing).unwrap()).unwrap(); + let error = verify_trust(&path, &command).unwrap_err(); + assert!( + error.contains("expected exactly one trust approval"), + "{error}" + ); + + let mut with_opaque_entry = trusted_hooks(None, None, &command, &relay, UNIX_EPOCH).unwrap(); + with_opaque_entry["approvals"] + .as_array_mut() + .unwrap() + .push(json!({"metadata": "unrelated"})); + std::fs::write(&path, serde_json::to_vec(&with_opaque_entry).unwrap()).unwrap(); + verify_trust(&path, &command).unwrap(); +} + +#[test] +fn install_is_verified_idempotent_and_rotates_the_generation() { + let temp = tempfile::tempdir().unwrap(); + let relay = relay_binary(temp.path()); + let paths = paths(&temp.path().join("hermes")); + let environment = vec!["OTEL_SERVICE_NAME".into()]; + let now = UNIX_EPOCH + Duration::from_secs(1_700_000_000); + + let written = + install_persistent_with(paths.clone(), &relay, &environment, None, now, atomic_write) + .unwrap(); + assert_eq!(written, paths.all()); + let first_generation = + crate::installation::generation::InstallGeneration::capture(paths.generation.clone()) + .unwrap() + .token() + .to_owned(); + let first_config = yaml(&paths.config); + let first_command = first_config["hooks"]["on_session_start"][0]["command"] + .as_str() + .unwrap() + .to_string(); + assert_eq!( + first_config["mcp_servers"][MCP_SERVER_NAME]["env"][GENERATION_TOKEN_ENV], + json!(first_generation) + ); + assert!(crate::hook_assertions::command_has_arguments( + &first_command, + &["--generation-token", &first_generation] + )); + + install_persistent_with(paths.clone(), &relay, &environment, None, now, atomic_write).unwrap(); + let second_generation = + crate::installation::generation::InstallGeneration::capture(paths.generation.clone()) + .unwrap() + .token() + .to_owned(); + assert_ne!(first_generation, second_generation); + + let config = yaml(&paths.config); + let second_command = + persistent_hook_command(&relay, &paths.generation, &second_generation).unwrap(); + assert_eq!( + config["hooks"]["on_session_start"] + .as_array() + .unwrap() + .iter() + .filter(|group| group["command"] == json!(second_command)) + .count(), + 1 + ); + assert_eq!( + config["hooks"]["on_session_start"] + .as_array() + .unwrap() + .iter() + .filter(|group| group["command"] == json!(first_command)) + .count(), + 0 + ); + assert_eq!( + config["mcp_servers"][MCP_SERVER_NAME]["env"][GENERATION_FILE_ENV], + json!(paths.generation.display().to_string()) + ); + assert_eq!( + config["mcp_servers"][MCP_SERVER_NAME]["env"][GENERATION_TOKEN_ENV], + json!(second_generation) + ); + assert_ne!( + first_config["mcp_servers"][MCP_SERVER_NAME]["env"][GENERATION_TOKEN_ENV], + config["mcp_servers"][MCP_SERVER_NAME]["env"][GENERATION_TOKEN_ENV] + ); + assert!(crate::hook_assertions::command_has_arguments( + &first_command, + &["--generation-token", &first_generation] + )); + assert!(!crate::hook_assertions::command_has_arguments( + &first_command, + &["--generation-token", &second_generation] + )); + assert_eq!( + config["mcp_servers"][MCP_SERVER_NAME]["env"]["OTEL_SERVICE_NAME"], + json!("${OTEL_SERVICE_NAME}") + ); + assert_eq!( + json_file(&paths.allowlist)["approvals"] + .as_array() + .unwrap() + .len(), + CodingAgent::Hermes.hook_events().len() + ); +} + +#[test] +fn reinstall_verifies_generation_through_the_existing_retirement_transaction() { + let temp = tempfile::tempdir().unwrap(); + let relay = relay_binary(temp.path()); + let paths = paths(&temp.path().join("hermes")); + install_persistent_with(paths.clone(), &relay, &[], None, UNIX_EPOCH, atomic_write).unwrap(); + let first_token = InstallGeneration::capture(paths.generation.clone()) + .unwrap() + .token() + .to_owned(); + let mut retirement = GenerationRetirement::acquire(&paths.generation) + .unwrap() + .unwrap(); + retirement.invalidate_for_replacement().unwrap(); + + let result = install_persistent_with_generation( + paths.clone(), + &relay, + &[], + None, + Some(&retirement), + UNIX_EPOCH, + atomic_write, + ); + finish_generation_mutation(result, Some(&mut retirement), "install").unwrap(); + drop(retirement); + + let second_token = InstallGeneration::capture(paths.generation) + .unwrap() + .token() + .to_owned(); + assert_ne!(first_token, second_token); +} + +#[test] +fn diagnosis_rejects_a_stale_mcp_generation_identity() { + let temp = tempfile::tempdir().unwrap(); + let relay = relay_binary(temp.path()); + let paths = paths(&temp.path().join("hermes")); + install_persistent_with(paths.clone(), &relay, &[], None, UNIX_EPOCH, atomic_write).unwrap(); + let mut config = yaml(&paths.config); + config["mcp_servers"][MCP_SERVER_NAME]["env"][GENERATION_TOKEN_ENV] = json!("stale-generation"); + std::fs::write(&paths.config, serde_yaml::to_string(&config).unwrap()).unwrap(); + + let error = diagnose_persistent(&paths.config).unwrap_err(); + + assert!( + error.contains("expected generation identity is stale"), + "{error}" + ); +} + +#[test] +fn install_rolls_back_config_allowlist_and_generation_after_write_failure() { + let temp = tempfile::tempdir().unwrap(); + let relay = relay_binary(temp.path()); + let paths = paths(&temp.path().join("hermes")); + std::fs::create_dir_all(paths.config.parent().unwrap()).unwrap(); + let originals = [ + (&paths.config, b"model: original\n".as_slice()), + ( + &paths.allowlist, + b"{\"approvals\":[{\"event\":\"x\",\"command\":\"custom\"}]}\n".as_slice(), + ), + (&paths.generation, b"original-generation\n".as_slice()), + ]; + for (path, bytes) in originals { + std::fs::write(path, bytes).unwrap(); + } + let before = paths.all().map(|path| std::fs::read(path).unwrap()); + let writes = Cell::new(0); + + let error = install_persistent_with( + paths.clone(), + &relay, + &[], + None, + UNIX_EPOCH, + |path, bytes| { + let write = writes.get() + 1; + writes.set(write); + if write == 3 { + return Err("injected config write failure".into()); + } + atomic_write(path, bytes) + }, + ) + .unwrap_err() + .to_string(); + + assert!(error.contains("injected config write failure"), "{error}"); + for (index, path) in paths.all().iter().enumerate() { + assert_eq!( + std::fs::read(path).unwrap(), + before[index], + "{}", + path.display() + ); + } +} + +#[cfg(unix)] +#[test] +fn install_rollback_restores_original_file_permissions() { + use std::os::unix::fs::PermissionsExt; + + let temp = tempfile::tempdir().unwrap(); + let relay = relay_binary(temp.path()); + let paths = paths(&temp.path().join("hermes")); + std::fs::create_dir_all(paths.config.parent().unwrap()).unwrap(); + let originals = [ + (&paths.config, b"model: original\n".as_slice(), 0o640), + ( + &paths.allowlist, + b"{\"approvals\":[{\"event\":\"x\",\"command\":\"custom\"}]}\n".as_slice(), + 0o644, + ), + ( + &paths.generation, + b"original-generation\n".as_slice(), + 0o600, + ), + ]; + for (path, bytes, mode) in originals { + std::fs::write(path, bytes).unwrap(); + std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)).unwrap(); + } + let expected_modes = paths + .all() + .map(|path| std::fs::metadata(path).unwrap().permissions().mode() & 0o777); + let writes = Cell::new(0); + + install_persistent_with( + paths.clone(), + &relay, + &[], + None, + UNIX_EPOCH, + |path, bytes| { + let write = writes.get() + 1; + writes.set(write); + if write == 3 { + return Err("injected config write failure".into()); + } + atomic_write(path, bytes)?; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)) + .map_err(|error| error.to_string()) + }, + ) + .unwrap_err(); + + for (index, path) in paths.all().iter().enumerate() { + assert_eq!( + std::fs::metadata(path).unwrap().permissions().mode() & 0o777, + expected_modes[index], + "{}", + path.display() + ); + } +} + +#[test] +fn composed_install_rollback_restores_the_visible_preexisting_generation() { + let temp = tempfile::tempdir().unwrap(); + let relay = relay_binary(temp.path()); + let paths = paths(&temp.path().join("hermes")); + std::fs::create_dir_all(paths.config.parent().unwrap()).unwrap(); + install_persistent_with(paths.clone(), &relay, &[], None, UNIX_EPOCH, atomic_write).unwrap(); + let previous = + crate::installation::generation::InstallGeneration::capture(paths.generation.clone()) + .unwrap(); + let mut retirement = GenerationRetirement::acquire(&paths.generation) + .unwrap() + .unwrap(); + retirement.invalidate_for_replacement().unwrap(); + let writes = Cell::new(0); + + let result = install_persistent_with( + paths.clone(), + &relay, + &[], + None, + UNIX_EPOCH, + |path, bytes| { + let write = writes.get() + 1; + writes.set(write); + if write == 3 { + return Err("injected composed install failure".into()); + } + atomic_write(path, bytes) + }, + ); + let error = finish_generation_mutation(result, Some(&mut retirement), "install") + .unwrap_err() + .to_string(); + + assert!( + error.contains("injected composed install failure"), + "{error}" + ); + previous.verify_current().unwrap(); + crate::installation::generation::InstallGeneration::capture(paths.generation).unwrap(); +} + +#[test] +fn install_rolls_back_after_post_write_verification_failure() { + let temp = tempfile::tempdir().unwrap(); + let relay = relay_binary(temp.path()); + let paths = paths(&temp.path().join("hermes")); + std::fs::create_dir_all(paths.config.parent().unwrap()).unwrap(); + std::fs::write(&paths.config, "model: original\n").unwrap(); + std::fs::write(&paths.allowlist, "{\"approvals\":[]}\n").unwrap(); + std::fs::write(&paths.generation, "old\n").unwrap(); + let before = paths.all().map(|path| std::fs::read(path).unwrap()); + let corrupted = Cell::new(false); + + let error = install_persistent_with( + paths.clone(), + &relay, + &[], + None, + UNIX_EPOCH, + |path, bytes| { + if path == paths.config && !corrupted.replace(true) { + return atomic_write(path, b"hooks: invalid-shape\n"); + } + atomic_write(path, bytes) + }, + ) + .unwrap_err() + .to_string(); + + assert!( + error.contains("Hermes MCP server did not persist exactly"), + "{error}" + ); + for (index, path) in paths.all().iter().enumerate() { + assert_eq!( + std::fs::read(path).unwrap(), + before[index], + "{}", + path.display() + ); + } +} + +#[test] +fn uninstall_removes_only_relay_owned_hermes_state() { + let temp = tempfile::tempdir().unwrap(); + let relay = relay_binary(temp.path()); + let paths = paths(&temp.path().join("hermes")); + std::fs::create_dir_all(paths.config.parent().unwrap()).unwrap(); + std::fs::write( + &paths.config, + "model: keep\nmcp_servers:\n filesystem:\n command: fs-mcp\nhooks:\n custom_event:\n - command: custom-hook\n", + ) + .unwrap(); + std::fs::write( + &paths.allowlist, + "{\"owner\":\"user\",\"approvals\":[{\"event\":\"custom_event\",\"command\":\"custom-hook\"}]}\n", + ) + .unwrap(); + install_persistent_with(paths.clone(), &relay, &[], None, UNIX_EPOCH, atomic_write).unwrap(); + + let removed = uninstall_persistent_with(paths.clone(), atomic_write).unwrap(); + + assert_eq!(removed, paths.all()); + assert!(!paths.generation.exists()); + let config = yaml(&paths.config); + assert_eq!(config["model"], json!("keep")); + assert_eq!( + config["mcp_servers"]["filesystem"]["command"], + json!("fs-mcp") + ); + assert!(config["mcp_servers"].get(MCP_SERVER_NAME).is_none()); + assert_eq!( + config["hooks"]["custom_event"][0]["command"], + json!("custom-hook") + ); + let allowlist = json_file(&paths.allowlist); + assert_eq!(allowlist["owner"], json!("user")); + assert_eq!(allowlist["approvals"].as_array().unwrap().len(), 1); + assert_eq!(allowlist["approvals"][0]["command"], json!("custom-hook")); +} + +#[test] +fn uninstall_rolls_back_every_file_when_commit_fails() { + let temp = tempfile::tempdir().unwrap(); + let relay = relay_binary(temp.path()); + let paths = paths(&temp.path().join("hermes")); + std::fs::create_dir_all(paths.config.parent().unwrap()).unwrap(); + std::fs::write(&paths.config, "model: keep\n").unwrap(); + std::fs::write(&paths.allowlist, "{\"owner\":\"keep\"}\n").unwrap(); + install_persistent_with(paths.clone(), &relay, &[], None, UNIX_EPOCH, atomic_write).unwrap(); + let before = paths.all().map(|path| std::fs::read(path).unwrap()); + let writes = Cell::new(0); + + let error = uninstall_persistent_with(paths.clone(), |path, bytes| { + let write = writes.get() + 1; + writes.set(write); + if write == 2 { + return Err("injected uninstall config failure".into()); + } + atomic_write(path, bytes) + }) + .unwrap_err() + .to_string(); + + assert!( + error.contains("injected uninstall config failure"), + "{error}" + ); + for (index, path) in paths.all().iter().enumerate() { + assert_eq!( + std::fs::read(path).unwrap(), + before[index], + "{}", + path.display() + ); + } +} + +#[test] +fn composed_uninstall_rollback_restores_the_visible_preexisting_generation() { + let temp = tempfile::tempdir().unwrap(); + let relay = relay_binary(temp.path()); + let paths = paths(&temp.path().join("hermes")); + std::fs::create_dir_all(paths.config.parent().unwrap()).unwrap(); + std::fs::write(&paths.config, "model: keep\n").unwrap(); + std::fs::write(&paths.allowlist, "{\"owner\":\"keep\"}\n").unwrap(); + install_persistent_with(paths.clone(), &relay, &[], None, UNIX_EPOCH, atomic_write).unwrap(); + let previous = + crate::installation::generation::InstallGeneration::capture(paths.generation.clone()) + .unwrap(); + let mut retirement = GenerationRetirement::acquire(&paths.generation) + .unwrap() + .unwrap(); + retirement.invalidate_for_replacement().unwrap(); + let writes = Cell::new(0); + + let result = uninstall_persistent_with(paths.clone(), |path, bytes| { + let write = writes.get() + 1; + writes.set(write); + if write == 2 { + return Err("injected composed uninstall failure".into()); + } + atomic_write(path, bytes) + }); + let error = finish_generation_mutation(result, Some(&mut retirement), "uninstall") + .unwrap_err() + .to_string(); + + assert!( + error.contains("injected composed uninstall failure"), + "{error}" + ); + previous.verify_current().unwrap(); + crate::installation::generation::InstallGeneration::capture(paths.generation).unwrap(); +} + +#[test] +fn uninstall_noops_without_creating_a_hermes_home() { + let temp = tempfile::tempdir().unwrap(); + let home = temp.path().join("missing-hermes-home"); + let config = home.join("config.yaml"); + + assert!(uninstall_persistent(&config).unwrap().is_empty()); + assert!(!home.exists()); +} + +#[test] +fn unrelated_hermes_files_are_not_owned_or_rewritten_by_uninstall() { + let temp = tempfile::tempdir().unwrap(); + let paths = paths(&temp.path().join("hermes")); + std::fs::create_dir_all(paths.config.parent().unwrap()).unwrap(); + let config = b"# preserve this exact formatting\nmodel: custom\nmcp_servers:\n nemo-relay:\n command: foreign-mcp\n args: [serve]\n"; + let allowlist = b"{ \"approvals\": [{\"event\":\"custom\",\"command\":\"custom-hook\"}] }\n"; + std::fs::write(&paths.config, config).unwrap(); + std::fs::write(&paths.allowlist, allowlist).unwrap(); + + assert!(!persistent_state_exists(&paths.config)); + assert!(uninstall_persistent(&paths.config).unwrap().is_empty()); + assert_eq!(std::fs::read(&paths.config).unwrap(), config); + assert_eq!(std::fs::read(&paths.allowlist).unwrap(), allowlist); + assert!(!paths.generation.exists()); +} + +#[test] +fn persistent_state_detection_recognizes_each_relay_owned_surface() { + let temp = tempfile::tempdir().unwrap(); + let relay = relay_binary(temp.path()); + let roots = ["generation", "mcp", "hook", "approval"].map(|name| { + let paths = paths(&temp.path().join(name)); + std::fs::create_dir_all(paths.config.parent().unwrap()).unwrap(); + paths + }); + + std::fs::write(&roots[0].generation, "active\n").unwrap(); + std::fs::write( + &roots[1].config, + serde_yaml::to_string(&json!({ + "mcp_servers": {MCP_SERVER_NAME: expected_mcp_server( + &relay, + &roots[1].generation, + TEST_GENERATION_TOKEN, + &[] + )} + })) + .unwrap(), + ) + .unwrap(); + std::fs::write( + &roots[2].config, + serde_yaml::to_string(&json!({ + "hooks": { + "on_session_start": [{"command": persistent_hook_command( + &relay, + &roots[2].generation, + TEST_GENERATION_TOKEN + ).unwrap()}] + } + })) + .unwrap(), + ) + .unwrap(); + std::fs::write( + &roots[3].allowlist, + serde_json::to_vec(&json!({ + "approvals": [{ + "event": "on_session_start", + "command": persistent_hook_command( + &relay, + &roots[3].generation, + TEST_GENERATION_TOKEN + ).unwrap() + }] + })) + .unwrap(), + ) + .unwrap(); + + for paths in [&roots[0], &roots[1], &roots[3]] { + assert!( + persistent_state_exists(&paths.config), + "managed state at {} was not detected", + paths.config.display() + ); + } + assert!(!persistent_state_exists(&roots[2].config)); +} + +#[test] +fn transparent_config_suppresses_only_the_managed_mcp_and_uses_one_relay_hook() { + let temp = tempfile::tempdir().unwrap(); + let relay = relay_binary(temp.path()); + let command = crate::hooks::transparent_hook_forward_command( + &relay, + CodingAgent::Hermes, + "http://127.0.0.1:1234", + ) + .unwrap(); + let generation = temp.path().join(GENERATION_FILE_NAME); + let persistent_command = + persistent_hook_command(&relay, &generation, TEST_GENERATION_TOKEN).unwrap(); + let mut existing = persistent_config( + None, + &relay, + &persistent_command, + &generation, + TEST_GENERATION_TOKEN, + &[], + ) + .unwrap(); + existing["mcp_servers"]["filesystem"] = json!({"command": "fs-mcp"}); + existing["hooks"]["on_session_start"] + .as_array_mut() + .unwrap() + .push(json!({"command": "custom-hook"})); + let existing = serde_yaml::to_string(&existing).unwrap(); + let patched: Value = serde_yaml::from_str( + &transparent_config(&existing, &relay, "http://127.0.0.1:1234").unwrap(), + ) + .unwrap(); + + assert!(patched["mcp_servers"].get(MCP_SERVER_NAME).is_none()); + assert_eq!( + patched["mcp_servers"]["filesystem"]["command"], + json!("fs-mcp") + ); + for event in CodingAgent::Hermes.hook_events() { + let groups = patched["hooks"][event].as_array().unwrap(); + assert_eq!( + groups + .iter() + .filter_map(|group| group.get("command").and_then(Value::as_str)) + .filter(|candidate| **candidate == command) + .count(), + 1, + "event {event}" + ); + assert!( + groups + .iter() + .any(|group| group["command"] == json!(command)) + ); + } + assert!( + patched["hooks"]["on_session_start"] + .as_array() + .unwrap() + .iter() + .any(|group| group["command"] == json!("custom-hook")) + ); +} + +#[test] +fn malformed_user_files_fail_before_any_state_is_replaced() { + let temp = tempfile::tempdir().unwrap(); + let relay = relay_binary(temp.path()); + let paths = paths(&temp.path().join("hermes")); + std::fs::create_dir_all(paths.config.parent().unwrap()).unwrap(); + std::fs::write(&paths.config, "hooks: [not-an-object]\n").unwrap(); + std::fs::write(&paths.allowlist, "{\"approvals\":[]}").unwrap(); + std::fs::write(&paths.generation, "old\n").unwrap(); + let before = paths.all().map(|path| std::fs::read(path).unwrap()); + + assert!( + install_persistent_with(paths.clone(), &relay, &[], None, UNIX_EPOCH, atomic_write,) + .is_err() + ); + for (index, path) in paths.all().iter().enumerate() { + assert_eq!(std::fs::read(path).unwrap(), before[index]); + } +} + +#[test] +fn hermes_entrypoints_reject_missing_or_foreign_relay_binaries() { + let temp = tempfile::tempdir().unwrap(); + let config_path = temp.path().join("hermes/config.yaml"); + let missing_relay = temp.path().join("missing/nemo-relay"); + + let error = install_persistent(&config_path, &missing_relay) + .unwrap_err() + .to_string(); + assert!(error.contains("missing or not executable"), "{error}"); + + std::fs::create_dir_all(config_path.parent().unwrap()).unwrap(); + std::fs::write( + &config_path, + format!( + "mcp_servers:\n {MCP_SERVER_NAME}:\n command: {}\n args: [mcp]\n", + missing_relay.display() + ), + ) + .unwrap(); + let error = configured_relay_executable(&config_path).unwrap_err(); + assert!(error.contains("not a managed Relay MCP client"), "{error}"); + + let foreign = json!({ + "mcp_servers": { + MCP_SERVER_NAME: { + "command": "foreign-mcp", + "args": ["serve"] + } + } + }); + let error = relay_executable_from_config(&foreign).unwrap_err(); + assert!(error.contains("not a managed Relay MCP client"), "{error}"); +} + +#[test] +fn hermes_diagnosis_validates_binary_bind_generation_and_environment() { + let temp = tempfile::tempdir().unwrap(); + let _config_home = XdgConfigHomeScope::enter(&temp.path().join("xdg")); + let relay = relay_binary(temp.path()); + let paths = paths(&temp.path().join("hermes")); + install_persistent_with(paths.clone(), &relay, &[], None, UNIX_EPOCH, atomic_write).unwrap(); + + let original = yaml(&paths.config); + std::fs::remove_file(&relay).unwrap(); + let error = diagnose_persistent(&paths.config).unwrap_err(); + assert!(error.contains("missing or not executable"), "{error}"); + + let relay = relay_binary(temp.path()); + let mut wrong_bind = original.clone(); + wrong_bind["mcp_servers"][MCP_SERVER_NAME]["env"]["NEMO_RELAY_GATEWAY_BIND"] = + json!("127.0.0.1:1"); + std::fs::write(&paths.config, serde_yaml::to_string(&wrong_bind).unwrap()).unwrap(); + let error = diagnose_persistent(&paths.config).unwrap_err(); + assert!(error.contains("not a managed Relay MCP client"), "{error}"); + + let mut wrong_generation = original.clone(); + wrong_generation["mcp_servers"][MCP_SERVER_NAME]["env"][GENERATION_FILE_ENV] = + json!(temp.path().join("wrong-generation").display().to_string()); + std::fs::write( + &paths.config, + serde_yaml::to_string(&wrong_generation).unwrap(), + ) + .unwrap(); + let error = diagnose_persistent(&paths.config).unwrap_err(); + assert!( + error.contains("generation fence points at the wrong file"), + "{error}" + ); + + let mut missing_environment = original; + assert!( + missing_environment["mcp_servers"][MCP_SERVER_NAME]["env"] + .as_object_mut() + .unwrap() + .remove("OPENAI_API_KEY") + .is_some() + ); + std::fs::write( + &paths.config, + serde_yaml::to_string(&missing_environment).unwrap(), + ) + .unwrap(); + let error = diagnose_persistent(&paths.config).unwrap_err(); + assert!(error.contains("missing environment names"), "{error}"); + assert!(error.contains("OPENAI_API_KEY"), "{error}"); + assert!(error.contains("install hermes --force"), "{error}"); + + assert!(relay.exists()); +} + +#[test] +fn hermes_generation_finish_preserves_primary_errors_and_reports_restore_failures() { + let primary = CliError::Install("primary failure".into()); + let error = finish_generation_mutation::<()>(Err(primary), None, "install") + .unwrap_err() + .to_string(); + assert!(error.contains("primary failure"), "{error}"); + + let temp = tempfile::tempdir().unwrap(); + let generation = temp.path().join(GENERATION_FILE_NAME); + crate::installation::generation::write_new_generation(&generation).unwrap(); + let mut retirement = GenerationRetirement::acquire(&generation).unwrap().unwrap(); + retirement.invalidate_for_replacement().unwrap(); + std::fs::write(&generation, "foreign-generation\n").unwrap(); + + let error = finish_generation_mutation::<()>( + Err(CliError::Install("mutation failed".into())), + Some(&mut retirement), + "install", + ) + .unwrap_err() + .to_string(); + assert!(error.contains("mutation failed"), "{error}"); + assert!(error.contains("additionally failed to restore"), "{error}"); +} + +#[test] +fn hermes_uninstall_and_verification_reject_malformed_or_residual_state() { + let temp = tempfile::tempdir().unwrap(); + let relay = relay_binary(temp.path()); + let hermes_paths = paths(&temp.path().join("hermes")); + install_persistent_with( + hermes_paths.clone(), + &relay, + &[], + None, + UNIX_EPOCH, + atomic_write, + ) + .unwrap(); + + let config = yaml(&hermes_paths.config); + let command = config["hooks"]["on_session_start"][0]["command"] + .as_str() + .unwrap() + .to_string(); + let token = InstallGeneration::capture(hermes_paths.generation.clone()) + .unwrap() + .token() + .to_owned(); + let expected_environment = forwarded_environment_names(&[], None); + + let mut duplicate_hook = config.clone(); + duplicate_hook["hooks"]["on_session_start"] + .as_array_mut() + .unwrap() + .push(json!({"command": command})); + let error = verify_hook_definitions(&duplicate_hook, &command).unwrap_err(); + assert!( + error.contains("exactly one trusted Relay handler"), + "{error}" + ); + + let mut harmless_missing_command = config.clone(); + harmless_missing_command["hooks"] + .as_object_mut() + .unwrap() + .insert("custom".into(), json!([{"timeout": 1}])); + verify_hook_definitions(&harmless_missing_command, &command).unwrap(); + + verify_install( + &hermes_paths, + &relay, + &command, + &expected_environment, + &token, + None, + ) + .unwrap(); + + let mut mismatched_environment = config.clone(); + let environment_name = expected_environment + .first() + .expect("persistent MCP environment is non-empty"); + mismatched_environment["mcp_servers"][MCP_SERVER_NAME]["env"][environment_name] = + json!("unexpected-value"); + std::fs::write( + &hermes_paths.config, + serde_yaml::to_string(&mismatched_environment).unwrap(), + ) + .unwrap(); + let error = diagnose_persistent(&hermes_paths.config).unwrap_err(); + assert!(error.contains(environment_name), "{error}"); + + install_persistent_with( + hermes_paths.clone(), + &relay, + &expected_environment, + None, + UNIX_EPOCH, + atomic_write, + ) + .unwrap(); + let config = yaml(&hermes_paths.config); + let command = config["hooks"]["on_session_start"][0]["command"] + .as_str() + .unwrap() + .to_string(); + let expected_token = InstallGeneration::capture(hermes_paths.generation.clone()) + .unwrap() + .token() + .to_owned(); + crate::installation::generation::write_new_generation(&hermes_paths.generation).unwrap(); + let error = verify_install( + &hermes_paths, + &relay, + &command, + &expected_environment, + &expected_token, + None, + ) + .unwrap_err(); + assert!( + error.contains("generation did not persist exactly"), + "{error}" + ); + + let malformed_paths = paths(&temp.path().join("malformed")); + std::fs::create_dir_all(malformed_paths.config.parent().unwrap()).unwrap(); + std::fs::write(&malformed_paths.allowlist, r#"{"approvals":{}}"#).unwrap(); + let error = uninstall_persistent_with(malformed_paths, atomic_write) + .unwrap_err() + .to_string(); + assert!(error.contains("approvals must be an array"), "{error}"); +} + +#[test] +fn hermes_uninstall_verifier_identifies_each_residual_owned_surface() { + let temp = tempfile::tempdir().unwrap(); + let relay = relay_binary(temp.path()); + let paths = paths(&temp.path().join("hermes")); + install_persistent_with(paths.clone(), &relay, &[], None, UNIX_EPOCH, atomic_write).unwrap(); + + let command = owned_command_from_config(&yaml(&paths.config), Some(&paths.generation)); + let error = verify_uninstall(&paths, command.as_deref()).unwrap_err(); + assert!(error.contains("generation fence still exists"), "{error}"); + + std::fs::remove_file(&paths.generation).unwrap(); + let error = verify_uninstall(&paths, command.as_deref()).unwrap_err(); + assert!( + error.contains("managed Hermes Relay config still exists"), + "{error}" + ); + + std::fs::remove_file(&paths.config).unwrap(); + let error = verify_uninstall(&paths, command.as_deref()).unwrap_err(); + assert!( + error.contains("managed Hermes Relay trust approval still exists"), + "{error}" + ); +} + +#[test] +fn hermes_file_helpers_report_path_lock_read_remove_and_restore_failures() { + let temp = tempfile::tempdir().unwrap(); + + let error = PersistentPaths::for_config(PathBuf::from("/")) + .unwrap_err() + .to_string(); + assert!(error.contains("has no parent directory"), "{error}"); + let error = acquire_install_lock(Path::new("/"), Duration::ZERO).unwrap_err(); + assert!(error.contains("has no parent directory"), "{error}"); + + let parent_file = temp.path().join("parent-file"); + std::fs::write(&parent_file, "file").unwrap(); + let error = acquire_allowlist_lock(&parent_file.join("allowlist"), Duration::ZERO).unwrap_err(); + assert!(error.contains("failed to create"), "{error}"); + let error = + acquire_allowlist_lock(&parent_file.join("nested/allowlist"), Duration::ZERO).unwrap_err(); + assert!(error.contains("failed to create"), "{error}"); + + let allowlist = temp.path().join("allowlist.json"); + let lock_dir = temp.path().join("allowlist.json.lock"); + std::fs::create_dir(&lock_dir).unwrap(); + let error = acquire_allowlist_lock(&allowlist, Duration::ZERO).unwrap_err(); + assert!( + error.contains("failed to open Hermes install lock"), + "{error}" + ); + + let held_config = temp.path().join("held/config.yaml"); + let _held = acquire_install_lock(&held_config, Duration::ZERO).unwrap(); + let error = acquire_install_lock(&held_config, Duration::from_millis(30)).unwrap_err(); + assert!(error.contains("timed out waiting"), "{error}"); + + let directory = temp.path().join("directory"); + std::fs::create_dir(&directory).unwrap(); + let error = read_optional_utf8(&directory).unwrap_err().to_string(); + assert!(error.contains("failed to read"), "{error}"); + let error = match FileSnapshot::capture(&directory) { + Ok(_) => panic!("directory snapshot unexpectedly succeeded"), + Err(error) => error.to_string(), + }; + assert!(error.contains("failed to snapshot"), "{error}"); + let error = remove_optional_file(&directory).unwrap_err(); + assert!(error.contains("failed to remove"), "{error}"); + remove_optional_file(&temp.path().join("missing")).unwrap(); + + let restored = temp.path().join("restored"); + std::fs::write(&restored, "original").unwrap(); + let snapshot = FileSnapshot::capture(&restored).unwrap(); + std::fs::remove_file(&restored).unwrap(); + let error = snapshot.restore(&mut |_path, _bytes| Ok(())).unwrap_err(); + assert!(error.contains("failed to restore permissions"), "{error}"); + + let absent = temp.path().join("absent"); + let snapshot = FileSnapshot::capture(&absent).unwrap(); + std::fs::write(&absent, "transient").unwrap(); + snapshot.restore(&mut atomic_write).unwrap(); + assert!(!absent.exists()); +} + +#[test] +fn hermes_rollback_reports_both_primary_and_snapshot_restore_errors() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("state"); + std::fs::write(&path, "original").unwrap(); + let snapshot = FileSnapshot::capture(&path).unwrap(); + let error = rollback_error::<(), _>( + "install", + "primary failure".into(), + &[snapshot], + &mut |_path, _bytes| Err("restore failure".into()), + ) + .unwrap_err() + .to_string(); + assert!(error.contains("primary failure"), "{error}"); + assert!( + error.contains("rollback also failed: restore failure"), + "{error}" + ); +} + +#[test] +fn hermes_uninstall_preserves_an_ambiguous_manual_allowlist() { + let temp = tempfile::tempdir().unwrap(); + let paths = paths(&temp.path().join("hermes")); + std::fs::create_dir_all(paths.allowlist.parent().unwrap()).unwrap(); + std::fs::write( + &paths.allowlist, + serde_json::to_vec(&json!({ + "approvals": [{ + "event": "on_session_start", + "command": "nemo-relay hook-forward hermes" + }] + })) + .unwrap(), + ) + .unwrap(); + + let affected = uninstall_persistent_with(paths.clone(), atomic_write).unwrap(); + + assert_eq!(affected, vec![paths.allowlist.clone()]); + assert!(paths.allowlist.exists()); + assert_eq!( + json_file(&paths.allowlist)["approvals"] + .as_array() + .unwrap() + .len(), + 1 + ); +} diff --git a/crates/cli/tests/coverage/agents/launcher_tests.rs b/crates/cli/tests/coverage/agents/launcher_tests.rs new file mode 100644 index 000000000..e9aa1e1d4 --- /dev/null +++ b/crates/cli/tests/coverage/agents/launcher_tests.rs @@ -0,0 +1,1823 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use super::*; +use crate::configuration::{AgentCommandConfig, GatewayConfig}; +use crate::hooks::generated_hooks; +use std::ffi::OsString; +use std::sync::Mutex; + +fn current_dir_lock() -> &'static Mutex<()> { + &crate::test_support::CWD_TEST_LOCK +} + +struct EnvScope { + _guard: std::sync::MutexGuard<'static, ()>, + values: Vec<(&'static str, Option)>, +} + +impl EnvScope { + fn set(values: &[(&'static str, Option<&std::ffi::OsStr>)]) -> Self { + let guard = crate::test_support::ENV_TEST_LOCK + .lock() + .unwrap_or_else(|error| error.into_inner()); + let previous = values + .iter() + .map(|(key, _)| (*key, std::env::var_os(key))) + .collect::>(); + for (key, value) in values { + unsafe { + match value { + Some(value) => std::env::set_var(key, value), + None => std::env::remove_var(key), + } + } + } + Self { + _guard: guard, + values: previous, + } + } + + fn without_managed_bootstrap() -> Self { + Self::set(&[ + (crate::bootstrap::state::BOOTSTRAP_STATE_DIR_ENV, None), + ("NEMO_RELAY_BOOTSTRAP_SHUTDOWN_TOKEN", None), + (crate::configuration::BOOTSTRAP_FINGERPRINT_ENV, None), + ]) + } +} + +impl Drop for EnvScope { + fn drop(&mut self) { + for (key, value) in self.values.drain(..) { + unsafe { + match value { + Some(value) => std::env::set_var(key, value), + None => std::env::remove_var(key), + } + } + } + } +} + +#[test] +fn infers_agent_from_command_or_uses_override() { + let command = RunOverrides { + agent: None, + config: None, + openai_base_url: None, + anthropic_base_url: None, + session_metadata: None, + plugin_config_path: None, + dry_run: false, + print: false, + command: vec!["/usr/bin/codex".into()], + }; + let (agent, argv) = resolve_agent_and_argv(&command, &AgentConfigs::default()).unwrap(); + assert_eq!(agent, CodingAgent::Codex); + assert_eq!(argv, vec!["/usr/bin/codex"]); + + let command = RunOverrides { + agent: Some(CodingAgent::ClaudeCode), + command: vec!["wrapper".into()], + ..command + }; + let (agent, _) = resolve_agent_and_argv(&command, &AgentConfigs::default()).unwrap(); + assert_eq!(agent, CodingAgent::ClaudeCode); +} + +#[test] +fn uses_configured_command_when_no_argv_is_supplied() { + let agents = AgentConfigs { + codex: AgentCommandConfig { + command: Some("codex --full-auto".into()), + hooks_path: None, + }, + ..AgentConfigs::default() + }; + let command = RunOverrides { + agent: Some(CodingAgent::Codex), + config: None, + openai_base_url: None, + anthropic_base_url: None, + session_metadata: None, + plugin_config_path: None, + dry_run: false, + print: false, + command: vec![], + }; + + let (agent, argv) = resolve_agent_and_argv(&command, &agents).unwrap(); + + assert_eq!(agent, CodingAgent::Codex); + assert_eq!(argv, vec!["codex", "--full-auto"]); +} + +#[test] +fn uses_configured_hermes_command_when_no_argv_is_supplied() { + let agents = AgentConfigs { + hermes: AgentCommandConfig { + command: Some("hermes --yolo chat".into()), + hooks_path: None, + }, + ..AgentConfigs::default() + }; + let command = RunOverrides { + agent: Some(CodingAgent::Hermes), + config: None, + openai_base_url: None, + anthropic_base_url: None, + session_metadata: None, + plugin_config_path: None, + dry_run: false, + print: false, + command: vec![], + }; + + let (agent, argv) = resolve_agent_and_argv(&command, &agents).unwrap(); + + assert_eq!(agent, CodingAgent::Hermes); + assert_eq!(argv, vec!["hermes", "--yolo", "chat"]); +} + +#[test] +fn inference_failure_has_actionable_message() { + let command = RunOverrides { + agent: None, + config: None, + openai_base_url: None, + anthropic_base_url: None, + session_metadata: None, + plugin_config_path: None, + dry_run: false, + print: false, + command: vec!["my-agent".into()], + }; + + let error = resolve_agent_and_argv(&command, &AgentConfigs::default()) + .unwrap_err() + .to_string(); + + assert!(error.contains("pass --agent claude")); +} + +#[test] +fn missing_command_without_agent_errors() { + // Bare `nemo-relay run` (no command, no --agent) errors — we have nothing to spawn and no + // argv[0] to infer an agent from. With --agent set, we fall back to the agent's default + // binary name (e.g., `hermes`), so that branch is exercised in the resolution test + // below rather than here. + let command = RunOverrides { + agent: None, + config: None, + openai_base_url: None, + anthropic_base_url: None, + session_metadata: None, + plugin_config_path: None, + dry_run: false, + print: false, + command: vec![], + }; + + let error = resolve_agent_and_argv(&command, &AgentConfigs::default()) + .unwrap_err() + .to_string(); + + assert!(error.contains("missing command")); +} + +#[test] +fn agent_without_configured_command_falls_back_to_default_binary() { + // `--agent hermes` with no `[agents.hermes] command = "..."` override resolves to the + // default executable name on $PATH. + let command = RunOverrides { + agent: Some(CodingAgent::Hermes), + config: None, + openai_base_url: None, + anthropic_base_url: None, + session_metadata: None, + plugin_config_path: None, + dry_run: false, + print: false, + command: vec![], + }; + + let (agent, argv) = resolve_agent_and_argv(&command, &AgentConfigs::default()).unwrap(); + assert_eq!(agent, CodingAgent::Hermes); + assert_eq!(argv, vec!["hermes"]); +} + +#[test] +fn agent_with_passthrough_args_appends_to_configured_command() { + // The easy-path uses this code path: `nemo-relay codex -- --model X` resolves to the + // configured (or default) codex command with `--model X` appended. + let command = RunOverrides { + agent: Some(CodingAgent::Codex), + config: None, + openai_base_url: None, + anthropic_base_url: None, + session_metadata: None, + plugin_config_path: None, + dry_run: false, + print: false, + command: vec!["--model".into(), "openai/openai/gpt-5.1-codex".into()], + }; + + let (_, argv) = resolve_agent_and_argv(&command, &AgentConfigs::default()).unwrap(); + assert_eq!( + argv, + vec!["codex", "--model", "openai/openai/gpt-5.1-codex"] + ); +} + +#[test] +fn default_and_configured_command_helpers_cover_empty_and_all_agents() { + assert_eq!(default_command_for(CodingAgent::ClaudeCode), "claude"); + assert_eq!(default_command_for(CodingAgent::Codex), "codex"); + assert_eq!(default_command_for(CodingAgent::Hermes), "hermes"); + + let agents = AgentConfigs { + codex: AgentCommandConfig { + command: Some(" ".into()), + hooks_path: None, + }, + ..AgentConfigs::default() + }; + assert!(configured_command(CodingAgent::Codex, &agents).is_none()); +} + +#[test] +fn prepares_codex_config_overrides() { + let _guard = current_dir_lock().lock().unwrap(); + let resolved = ResolvedConfig { + gateway: GatewayConfig::default(), + agents: AgentConfigs::default(), + ..ResolvedConfig::default() + }; + let prepared = PreparedAgentLaunch::new( + CodingAgent::Codex, + vec!["codex".into()], + "http://127.0.0.1:1234", + &resolved, + false, + ) + .unwrap(); + + assert!(!prepared.argv.iter().any(|arg| arg == "--profile")); + assert!(prepared.argv.contains(&"features.hooks=true".into())); + assert!( + prepared + .argv + .iter() + .any(|arg| arg == "model_provider=\"nemo-relay-openai\"") + ); + assert!( + prepared + .argv + .iter() + .any(|arg| arg.contains("model_providers.nemo-relay-openai") + && arg.contains("base_url=\"http://127.0.0.1:1234\"") + // Codex sends its own credentials (ChatGPT-Plus OAuth or OPENAI_API_KEY). + // When OPENAI_API_KEY is in the environment the gateway substitutes it; + // otherwise codex's own auth is forwarded as-is. + && arg.contains("requires_openai_auth=true") + && arg.contains("supports_websockets=false")) + ); + assert!( + !prepared + .argv + .iter() + .any(|arg| arg.contains("model_providers.openai")) + ); + assert!( + prepared + .argv + .iter() + .any(|arg| arg.contains("hooks.SessionStart")) + ); + let trust = prepared + .argv + .iter() + .find(|arg| arg.starts_with("hooks.state={")) + .unwrap(); + let expected_hooks = generated_hooks(CodingAgent::Codex, "ignored")["hooks"] + .as_object() + .unwrap() + .len(); + assert_eq!( + trust.matches("trusted_hash=\"sha256:").count(), + expected_hooks + ); + assert_eq!(trust.matches("enabled=true").count(), expected_hooks); + assert!( + prepared + .env + .contains(&(crate::configuration::TRANSPARENT_RUN_ENV.into(), "1".into())) + ); + let path = prepared + .env + .iter() + .find_map(|(name, value)| (name == "PATH").then_some(value)) + .expect("transparent run should set PATH for hook subprocesses"); + let current_exe_dir = std::env::current_exe() + .unwrap() + .parent() + .unwrap() + .to_path_buf(); + let entries = std::env::split_paths(path).collect::>(); + assert!(entries.iter().any(|entry| entry == ¤t_exe_dir)); + if !std::env::var_os("PATH") + .as_deref() + .map(std::env::split_paths) + .into_iter() + .flatten() + .any(|entry| entry == current_exe_dir) + { + assert_eq!(entries.last(), Some(¤t_exe_dir)); + } + prepared.restore().unwrap(); +} + +#[test] +fn prepares_codex_with_hooks_when_auth_missing() { + let _guard = current_dir_lock().lock().unwrap(); + let temp = tempfile::tempdir().unwrap(); + let _env = EnvScope::set(&[ + ("OPENAI_API_KEY", None), + ("HOME", Some(temp.path().as_os_str())), + ("USERPROFILE", None), + ]); + let resolved = ResolvedConfig { + gateway: GatewayConfig::default(), + agents: AgentConfigs::default(), + ..ResolvedConfig::default() + }; + + let prepared = PreparedAgentLaunch::new( + CodingAgent::Codex, + vec!["codex".into()], + "http://127.0.0.1:1234", + &resolved, + false, + ) + .unwrap(); + + assert!(prepared.argv.iter().any(|arg| arg == "features.hooks=true")); +} + +#[test] +fn codex_session_hook_trust_matches_codex_discovery_identity() { + let generated = generated_hooks(CodingAgent::Codex, "echo relay-probe"); + let group = generated["hooks"]["UserPromptSubmit"][0] + .as_object() + .unwrap(); + let handler = &group["hooks"].as_array().unwrap()[0]; + assert_eq!( + crate::agents::codex::launch::command_hook_hash("user_prompt_submit", group, handler) + .unwrap(), + "sha256:83a9834ee494ffbd4acc85377c579d2c954f9797a9b8832924a326a6a44b0660" + ); + + let state = crate::agents::codex::launch::session_hook_state_override(&generated).unwrap(); + assert_eq!( + state.matches("trusted_hash=\"sha256:").count(), + generated["hooks"].as_object().unwrap().len() + ); + assert!(state.contains("//config.toml:user_prompt_submit:0:0")); + assert!( + state.contains("sha256:83a9834ee494ffbd4acc85377c579d2c954f9797a9b8832924a326a6a44b0660") + ); + assert_eq!( + state.matches("enabled=true").count(), + generated["hooks"].as_object().unwrap().len() + ); + assert_eq!( + state.matches("enabled=false").count(), + generated["hooks"].as_object().unwrap().len() * 2 + ); + assert!( + state + .contains("nemo-relay-plugin@nemo-relay-local:hooks/hooks.json:user_prompt_submit:0:0") + ); + assert!(state.contains("nemo-relay-plugin@nemo-relay:hooks/hooks.json:user_prompt_submit:0:0")); +} + +#[test] +fn codex_preserves_profiles_and_prompt_arguments_without_temporary_config() { + let resolved = ResolvedConfig { + gateway: GatewayConfig::default(), + agents: AgentConfigs::default(), + ..ResolvedConfig::default() + }; + let prepared = PreparedAgentLaunch::new( + CodingAgent::Codex, + vec![ + "codex".into(), + "--profile".into(), + "root".into(), + "exec".into(), + "--profile=work".into(), + "ping".into(), + "--".into(), + "codex".into(), + ], + "http://127.0.0.1:1234", + &resolved, + false, + ) + .unwrap(); + + let separator = prepared.argv.iter().position(|arg| arg == "--").unwrap(); + assert_eq!(&prepared.argv[separator..], &["--", "codex"]); + assert!( + prepared.argv[..separator] + .windows(2) + .any(|pair| pair == ["--profile", "root"]) + ); + assert!( + prepared.argv[..separator] + .iter() + .any(|arg| arg == "--profile=work") + ); + assert_eq!( + prepared + .argv + .iter() + .filter(|arg| arg.as_str() == "codex") + .count(), + 2 + ); + assert!( + prepared.argv[1..separator] + .iter() + .any(|arg| arg == "features.hooks=true") + ); +} + +#[test] +fn exporter_destinations_describe_observability_outputs() { + let gateway = GatewayConfig { + plugin_config: Some(json!({ + "version": 1, + "components": [{ + "kind": OBSERVABILITY_PLUGIN_KIND, + "enabled": true, + "config": { + "version": 2, + "atof": { + "enabled": true, + "sinks": [ + { + "type": "file", + "output_directory": "logs", + "filename": "events.jsonl" + }, + { + "type": "stream", + "url": "https://user:secret@collector.example/atof?token=secret" + } + ] + }, + "atif": { + "enabled": true, + "output_directory": "trajectories", + "filename_template": "agent-{session_id}.json" + }, + "opentelemetry": { + "enabled": true, + "endpoint": "http://127.0.0.1:4318/v1/traces" + }, + "openinference": { + "enabled": true + } + } + }] + })), + ..GatewayConfig::default() + }; + + let destinations = exporter_destinations(&gateway); + + assert!(destinations.iter().any(|line| line + == &format!( + "ATOF {}", + PathBuf::from("logs").join("events.jsonl").display() + ))); + assert!( + destinations + .iter() + .any(|line| line == "ATOF https://collector.example/atof?token=%5BREDACTED%5D") + ); + assert!(destinations.iter().any(|line| line + == &format!( + "ATIF {}", + PathBuf::from("trajectories") + .join("agent-{session_id}.json") + .display() + ))); + assert!( + destinations + .iter() + .any(|line| line == "OpenTelemetry http://127.0.0.1:4318/v1/traces") + ); + assert!( + destinations + .iter() + .any(|line| line == "OpenInference OTLP endpoint from environment/default") + ); +} + +#[test] +fn exporter_destinations_describe_atif_remote_storage_instead_of_local_path() { + let gateway = GatewayConfig { + plugin_config: Some(json!({ + "version": 1, + "components": [{ + "kind": OBSERVABILITY_PLUGIN_KIND, + "enabled": true, + "config": { + "version": 1, + "atif": { + "enabled": true, + "output_directory": "trajectories", + "filename_template": "agent-{session_id}.json", + "storage": [ + {"type": "s3", "bucket": "traj-bucket", "key_prefix": "runs/"}, + {"type": "http", "endpoint": "https://collector.example/ingest"} + ] + } + } + }] + })), + ..GatewayConfig::default() + }; + + let destinations = exporter_destinations(&gateway); + + assert!( + destinations + .iter() + .any(|line| line == "ATIF s3://traj-bucket/runs") + ); + assert!( + destinations + .iter() + .any(|line| line == "ATIF https://collector.example/ingest") + ); + // The local path is skipped at runtime when storage is configured, so it must not be reported. + assert!( + !destinations + .iter() + .any(|line| line.contains("agent-{session_id}.json")) + ); +} + +#[test] +fn exporter_destinations_redact_url_credentials_and_query_values() { + assert_eq!( + sanitized_url("https://user:secret@example.test/ingest?token=secret&tenant=acme"), + "https://example.test/ingest?token=%5BREDACTED%5D&tenant=%5BREDACTED%5D" + ); + assert_eq!( + sanitized_url("not a url with secret"), + "configured endpoint" + ); +} + +#[test] +fn exporter_destinations_cover_invalid_disabled_and_missing_plugin_configs() { + let invalid_plugin = GatewayConfig { + plugin_config: Some(json!({"components": "not-a-list"})), + ..GatewayConfig::default() + }; + assert_eq!( + exporter_destinations(&invalid_plugin), + vec!["configured (invalid plugin config)".to_string()] + ); + + let disabled_observability = GatewayConfig { + plugin_config: Some(json!({ + "version": 1, + "components": [{ + "kind": OBSERVABILITY_PLUGIN_KIND, + "enabled": false, + "config": {"version": 1} + }] + })), + ..GatewayConfig::default() + }; + assert!(exporter_destinations(&disabled_observability).is_empty()); + + let invalid_observability = GatewayConfig { + plugin_config: Some(json!({ + "version": 1, + "components": [{ + "kind": OBSERVABILITY_PLUGIN_KIND, + "enabled": true, + "config": {"version": "bad"} + }] + })), + ..GatewayConfig::default() + }; + assert_eq!( + exporter_destinations(&invalid_observability), + vec!["Observability configured (invalid config)".to_string()] + ); + + assert!(exporter_destinations(&GatewayConfig::default()).is_empty()); +} + +#[test] +fn insert_after_host_uses_the_authoritative_executable_index() { + let mut argv = vec![ + "wrapper".to_string(), + "codex".to_string(), + "exec".to_string(), + "--".to_string(), + "codex".to_string(), + ]; + crate::process::insert_after_host(&mut argv, 1, ["--config".to_string()]); + assert_eq!( + argv, + vec!["wrapper", "codex", "--config", "exec", "--", "codex"] + ); +} + +#[test] +fn invocation_resolves_wrapper_host_before_appending_pass_through_arguments() { + let agents = AgentConfigs { + codex: AgentCommandConfig { + command: Some("wrapper -- codex".into()), + hooks_path: None, + }, + ..AgentConfigs::default() + }; + let command = RunOverrides { + agent: Some(CodingAgent::Codex), + config: None, + openai_base_url: None, + anthropic_base_url: None, + session_metadata: None, + plugin_config_path: None, + dry_run: false, + print: false, + command: vec!["exec".into(), "--".into(), "codex".into()], + }; + + let invocation = resolve_agent_invocation(&command, &agents).unwrap(); + assert_eq!(invocation.host_index, 2); + assert_eq!( + invocation.argv, + vec!["wrapper", "--", "codex", "exec", "--", "codex"] + ); +} + +#[test] +fn version_probe_preserves_known_wrappers_and_validates_opaque_ones() { + assert_eq!( + crate::process::version_probe_argv(CodingAgent::Codex, &["codex".into(), "exec".into()]), + vec!["codex", "--version"] + ); + assert_eq!( + crate::process::version_probe_argv( + CodingAgent::Codex, + &["npx".into(), "--yes".into(), "codex".into(), "exec".into(),], + ), + vec!["npx", "--yes", "codex", "--version"] + ); + assert_eq!( + crate::process::version_probe_argv( + CodingAgent::Hermes, + &["company-agent-wrapper".into(), "chat".into()], + ), + vec!["company-agent-wrapper", "chat", "--version"] + ); +} + +#[cfg(unix)] +#[tokio::test] +async fn wrapped_agent_version_probe_runs_through_the_wrapper() { + let temp = tempfile::tempdir().unwrap(); + let wrapper = temp.path().join("npx"); + std::fs::write( + &wrapper, + "#!/bin/sh\n[ \"$1\" = codex ] && [ \"$2\" = --version ] || exit 9\necho 'codex-cli 0.143.0'\n", + ) + .unwrap(); + make_executable(&wrapper); + let probe = crate::process::version_probe_argv( + CodingAgent::Codex, + &[wrapper.display().to_string(), "codex".into(), "exec".into()], + ); + + validate_agent_version(CodingAgent::Codex, &probe) + .await + .unwrap(); +} + +#[test] +fn prepares_claude_dry_run_without_writing_plugin() { + let resolved = ResolvedConfig { + gateway: GatewayConfig::default(), + agents: AgentConfigs::default(), + ..ResolvedConfig::default() + }; + let prepared = PreparedAgentLaunch::new( + CodingAgent::ClaudeCode, + vec!["claude".into()], + "http://127.0.0.1:1234", + &resolved, + true, + ) + .unwrap(); + + assert_eq!(prepared.argv[1], "--plugin-dir"); + assert_eq!(prepared.argv[2], ""); + assert!( + prepared + .env + .contains(&("ANTHROPIC_BASE_URL".into(), "http://127.0.0.1:1234".into())) + ); + assert!(prepared.notes[0].contains("would generate")); +} + +#[test] +fn prepares_claude_dry_inserts_plugin_dir_after_authoritative_agent_executable() { + let resolved = ResolvedConfig { + gateway: GatewayConfig::default(), + agents: AgentConfigs::default(), + ..ResolvedConfig::default() + }; + let prepared = PreparedAgentLaunch::new( + CodingAgent::ClaudeCode, + vec![ + "wrapper".into(), + "claude".into(), + "subcommand".into(), + "/opt/bin/claude".into(), + "--resume".into(), + ], + "http://127.0.0.1:1234", + &resolved, + true, + ) + .unwrap(); + + let plugin_index = prepared + .argv + .iter() + .position(|arg| arg == "--plugin-dir") + .expect("plugin dir arg"); + assert_eq!(prepared.argv[plugin_index - 1], "/opt/bin/claude"); + assert_eq!( + prepared.argv[plugin_index + 1], + "" + ); + assert_eq!(prepared.argv.last().map(String::as_str), Some("--resume")); + assert!(prepared.temp_dirs.is_empty()); +} + +#[test] +fn prepares_hermes_hook_environment() { + let _guard = current_dir_lock().lock().unwrap(); + let temp = tempfile::tempdir().unwrap(); + let hooks_path = temp.path().join("hermes-home/config.yaml"); + std::fs::create_dir_all(hooks_path.parent().unwrap()).unwrap(); + std::fs::write(&hooks_path, "model:\n default: test\n").unwrap(); + let state = hooks_path.parent().unwrap().join("state.db"); + std::fs::write(&state, "state").unwrap(); + let cache = hooks_path.parent().unwrap().join("cache"); + std::fs::create_dir(&cache).unwrap(); + std::fs::write(cache.join("entry"), "cached").unwrap(); + let resolved = ResolvedConfig { + gateway: GatewayConfig::default(), + agents: AgentConfigs { + hermes: AgentCommandConfig { + command: None, + hooks_path: Some(hooks_path.clone()), + }, + ..AgentConfigs::default() + }, + dynamic_plugins: Vec::new(), + ..ResolvedConfig::default() + }; + let prepared = PreparedAgentLaunch::new( + CodingAgent::Hermes, + vec!["hermes".into(), "chat".into()], + "http://127.0.0.1:1234", + &resolved, + false, + ) + .unwrap(); + + assert_eq!(prepared.argv, vec!["hermes", "chat"]); + assert!(prepared.env.contains(&( + "NEMO_RELAY_GATEWAY_URL".into(), + "http://127.0.0.1:1234".into() + ))); + assert!( + prepared + .env + .contains(&("HERMES_ACCEPT_HOOKS".into(), "1".into())) + ); + let overlay = prepared + .env + .iter() + .find_map(|(name, value)| (name == "HERMES_HOME").then(|| PathBuf::from(value))) + .expect("Hermes overlay path"); + let hooks = std::fs::read_to_string(overlay.join("config.yaml")).unwrap(); + let hooks: serde_json::Value = serde_yaml::from_str(&hooks).unwrap(); + assert!(crate::hook_assertions::value_has_command_arguments( + &hooks, + &[ + "hook-forward", + "hermes", + "--gateway-url", + "http://127.0.0.1:1234", + "--transparent-run", + ], + )); + assert!(overlay.join("state.db").exists()); + assert_eq!( + std::fs::read_to_string(overlay.join("cache/entry")).unwrap(), + "cached" + ); + std::fs::write(overlay.join("cache/through-overlay"), "live").unwrap(); + assert_eq!( + std::fs::read_to_string(cache.join("through-overlay")).unwrap(), + "live" + ); + assert_eq!( + std::fs::read_to_string(&hooks_path).unwrap(), + "model:\n default: test\n" + ); + assert!(prepared.notes[0].contains("isolated Hermes config overlay")); + + prepared.restore().unwrap(); + assert!(hooks_path.exists()); + assert!(!overlay.exists()); +} + +#[cfg(unix)] +#[test] +fn process_private_directories_are_owner_only() { + use std::os::unix::fs::PermissionsExt; + + let parent = tempfile::tempdir().unwrap(); + let path = crate::filesystem::temp::private_temp_dir(parent.path(), "relay-private").unwrap(); + assert_eq!( + std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, + 0o700 + ); + std::fs::remove_dir(path).unwrap(); +} + +#[test] +fn concurrent_hermes_runs_use_independent_overlays_without_mutating_user_config() { + let temp = tempfile::tempdir().unwrap(); + let config = temp.path().join("hermes/config.yaml"); + std::fs::create_dir_all(config.parent().unwrap()).unwrap(); + let original = "model:\n default: test\n"; + std::fs::write(&config, original).unwrap(); + let resolved = ResolvedConfig { + agents: AgentConfigs { + hermes: AgentCommandConfig { + hooks_path: Some(config.clone()), + ..AgentCommandConfig::default() + }, + ..AgentConfigs::default() + }, + ..ResolvedConfig::default() + }; + + let resolved = std::sync::Arc::new(resolved); + let barrier = std::sync::Arc::new(std::sync::Barrier::new(3)); + let spawn = |url: &'static str| { + let resolved = resolved.clone(); + let barrier = barrier.clone(); + std::thread::spawn(move || { + barrier.wait(); + PreparedAgentLaunch::new( + CodingAgent::Hermes, + vec!["hermes".into()], + url, + &resolved, + false, + ) + .unwrap() + }) + }; + let first = spawn("http://127.0.0.1:4001"); + let second = spawn("http://127.0.0.1:4002"); + barrier.wait(); + let first = first.join().unwrap(); + let second = second.join().unwrap(); + let overlay = |run: &PreparedAgentLaunch| { + run.env + .iter() + .find_map(|(name, value)| (name == "HERMES_HOME").then(|| PathBuf::from(value))) + .unwrap() + }; + let first_overlay = overlay(&first); + let second_overlay = overlay(&second); + + assert_ne!(first_overlay, second_overlay); + let first_config: serde_json::Value = + serde_yaml::from_str(&std::fs::read_to_string(first_overlay.join("config.yaml")).unwrap()) + .unwrap(); + let second_config: serde_json::Value = + serde_yaml::from_str(&std::fs::read_to_string(second_overlay.join("config.yaml")).unwrap()) + .unwrap(); + assert!(crate::hook_assertions::value_has_command_arguments( + &first_config, + &[ + "hook-forward", + "hermes", + "--gateway-url", + "http://127.0.0.1:4001", + "--transparent-run", + ], + )); + assert!(crate::hook_assertions::value_has_command_arguments( + &second_config, + &[ + "hook-forward", + "hermes", + "--gateway-url", + "http://127.0.0.1:4002", + "--transparent-run", + ], + )); + assert_eq!(std::fs::read_to_string(&config).unwrap(), original); + + first.restore().unwrap(); + assert!(!first_overlay.exists()); + assert!(second_overlay.exists()); + assert_eq!(std::fs::read_to_string(&config).unwrap(), original); + second.restore().unwrap(); +} + +#[test] +fn hermes_overlay_does_not_link_an_ancestor_entry_that_contains_it() { + let source_home = tempfile::tempdir().unwrap(); + let source_config = source_home.path().join("config.yaml"); + std::fs::write(&source_config, "model:\n default: test\n").unwrap(); + let overlay = source_home.path().join("overlay"); + std::fs::create_dir(&overlay).unwrap(); + + crate::agents::hermes::launch::populate_overlay( + &overlay, + source_home.path(), + &source_config, + "http://127.0.0.1:1234", + ) + .unwrap(); + + assert!(!overlay.join("overlay").exists()); + assert!(overlay.join("config.yaml").exists()); +} + +#[test] +fn prepares_hermes_dry_uses_home_path_without_writing_hooks() { + let _guard = current_dir_lock().lock().unwrap(); + let temp = tempfile::tempdir().unwrap(); + let _env = EnvScope::set(&[ + ("HERMES_HOME", None), + ("HOME", Some(temp.path().as_os_str())), + ("USERPROFILE", None), + ]); + let resolved = ResolvedConfig { + gateway: GatewayConfig::default(), + agents: AgentConfigs::default(), + ..ResolvedConfig::default() + }; + + let prepared = PreparedAgentLaunch::new( + CodingAgent::Hermes, + vec!["hermes".into()], + "http://127.0.0.1:1234", + &resolved, + true, + ) + .unwrap(); + + let hook_path = temp.path().join(".hermes/config.yaml"); + assert!(prepared.notes[0].contains(".hermes")); + assert!(prepared.notes[0].contains("config.yaml")); + assert!( + prepared + .env + .contains(&("HERMES_ACCEPT_HOOKS".into(), "1".into())) + ); + assert!(!hook_path.exists()); +} + +#[test] +fn hermes_hooks_path_prefers_configured_then_env_then_home() { + let _guard = current_dir_lock().lock().unwrap(); + let temp = tempfile::tempdir().unwrap(); + let configured = temp.path().join("configured.yaml"); + assert_eq!( + crate::agents::hermes::launch::hooks_path_for_launch(Some(&configured)).unwrap(), + configured + ); + + let _env = EnvScope::set(&[ + ("HERMES_HOME", Some(temp.path().as_os_str())), + ("HOME", None), + ("USERPROFILE", None), + ]); + assert_eq!( + crate::agents::hermes::launch::hooks_path_for_launch(None).unwrap(), + temp.path().join("config.yaml") + ); + + drop(_env); + let _env = EnvScope::set(&[ + ("HERMES_HOME", None), + ("HOME", Some(temp.path().as_os_str())), + ("USERPROFILE", None), + ]); + assert_eq!( + crate::agents::hermes::launch::hooks_path_for_launch(None).unwrap(), + temp.path().join(".hermes/config.yaml") + ); + + drop(_env); + let _env = EnvScope::set(&[("HERMES_HOME", None), ("HOME", None), ("USERPROFILE", None)]); + let error = crate::agents::hermes::launch::hooks_path_for_launch(None) + .unwrap_err() + .to_string(); + assert!(error.contains("could not resolve home directory")); +} + +#[test] +fn hermes_overlay_preserves_ambiguous_manual_mcp_and_original_file() { + let _guard = current_dir_lock().lock().unwrap(); + let temp = tempfile::tempdir().unwrap(); + let hooks_path = temp.path().join("hermes-home/config.yaml"); + std::fs::create_dir_all(hooks_path.parent().unwrap()).unwrap(); + let original = r#"mcp_servers: + nemo-relay: + command: nemo-relay + args: [mcp, --agent, hermes] + filesystem: + command: fs-mcp +hooks: + PreToolUse: [] +"#; + std::fs::write(&hooks_path, original).unwrap(); + let resolved = ResolvedConfig { + gateway: GatewayConfig::default(), + agents: AgentConfigs { + hermes: AgentCommandConfig { + command: None, + hooks_path: Some(hooks_path.clone()), + }, + ..AgentConfigs::default() + }, + ..ResolvedConfig::default() + }; + + let prepared = PreparedAgentLaunch::new( + CodingAgent::Hermes, + vec!["hermes".into(), "chat".into()], + "http://s", + &resolved, + false, + ) + .unwrap(); + + let overlay = prepared + .env + .iter() + .find_map(|(name, value)| (name == "HERMES_HOME").then(|| PathBuf::from(value))) + .unwrap(); + let patched = std::fs::read_to_string(overlay.join("config.yaml")).unwrap(); + let patched_yaml: serde_json::Value = serde_yaml::from_str(&patched).unwrap(); + assert!(crate::hook_assertions::value_has_command_arguments( + &patched_yaml, + &[ + "hook-forward", + "hermes", + "--gateway-url", + "http://s", + "--transparent-run", + ], + )); + assert_eq!( + patched_yaml["mcp_servers"]["nemo-relay"]["args"], + json!(["mcp", "--agent", "hermes"]) + ); + assert_eq!( + patched_yaml["mcp_servers"]["filesystem"]["command"], + json!("fs-mcp") + ); + assert_eq!(std::fs::read_to_string(&hooks_path).unwrap(), original); + prepared.restore().unwrap(); + assert!(!overlay.exists()); +} + +#[test] +fn prepares_claude_temp_plugin() { + let resolved = ResolvedConfig { + gateway: GatewayConfig::default(), + agents: AgentConfigs::default(), + ..ResolvedConfig::default() + }; + let prepared = PreparedAgentLaunch::new( + CodingAgent::ClaudeCode, + vec!["claude".into()], + "http://127.0.0.1:1234", + &resolved, + false, + ) + .unwrap(); + + let plugin_index = prepared + .argv + .iter() + .position(|arg| arg == "--plugin-dir") + .unwrap(); + let plugin_dir = PathBuf::from(&prepared.argv[plugin_index + 1]); + assert!(plugin_dir.join("hooks/hooks.json").exists()); + assert_eq!(prepared.argv[plugin_index + 2], "--settings"); + let settings_path = PathBuf::from(&prepared.argv[plugin_index + 3]); + let settings: serde_json::Value = + serde_json::from_slice(&std::fs::read(&settings_path).unwrap()).unwrap(); + assert_eq!( + settings["env"]["ANTHROPIC_BASE_URL"], + "http://127.0.0.1:1234" + ); + let hooks: serde_json::Value = + serde_json::from_slice(&std::fs::read(plugin_dir.join("hooks/hooks.json")).unwrap()) + .unwrap(); + assert!(crate::hook_assertions::value_has_command_arguments( + &hooks, + &[ + "hook-forward", + "claude", + "--gateway-url", + "http://127.0.0.1:1234", + "--transparent-run", + ], + )); + assert!( + prepared + .env + .contains(&("ANTHROPIC_BASE_URL".into(), "http://127.0.0.1:1234".into())) + ); + prepared.restore().unwrap(); + assert!(!plugin_dir.exists()); +} + +#[test] +fn claude_transparent_run_preserves_user_settings_and_prompt_boundary() { + let temp = tempfile::tempdir().unwrap(); + let source = temp.path().join("claude-settings.json"); + let original = br#"{"model":"claude-user-setting-sentinel","enabledPlugins":{"other@market":true,"nemo-relay-plugin@nemo-relay-local":true},"env":{"PRIVATE":"kept"}}"#; + std::fs::write(&source, original).unwrap(); + let resolved = ResolvedConfig { + gateway: GatewayConfig::default(), + agents: AgentConfigs::default(), + ..ResolvedConfig::default() + }; + let prepared = PreparedAgentLaunch::new( + CodingAgent::ClaudeCode, + vec![ + "claude".into(), + "--settings".into(), + source.display().to_string(), + "--settings={\"model\":\"ignored-second-source\"}".into(), + "--print".into(), + "ping".into(), + "--".into(), + "--settings".into(), + "literal-prompt-value".into(), + ], + "http://127.0.0.1:1234", + &resolved, + false, + ) + .unwrap(); + + assert_eq!(prepared.argv[1], "--plugin-dir"); + assert_eq!(prepared.argv[3], "--settings"); + let overlay: serde_json::Value = + serde_json::from_slice(&std::fs::read(&prepared.argv[4]).unwrap()).unwrap(); + assert_eq!(overlay["model"], "claude-user-setting-sentinel"); + assert_eq!(overlay["enabledPlugins"]["other@market"], true); + assert_eq!( + overlay["enabledPlugins"]["nemo-relay-plugin@nemo-relay-local"], + true + ); + assert_eq!(overlay["env"]["PRIVATE"], "kept"); + assert_eq!( + overlay["env"]["ANTHROPIC_BASE_URL"], + "http://127.0.0.1:1234" + ); + assert_ne!(overlay["model"], "ignored-second-source"); + assert!( + prepared + .argv + .windows(2) + .any(|pair| { pair == ["--settings", source.to_string_lossy().as_ref()] }) + ); + assert!( + prepared + .argv + .iter() + .any(|arg| arg.contains("ignored-second-source")) + ); + let separator = prepared.argv.iter().position(|arg| arg == "--").unwrap(); + assert_eq!( + &prepared.argv[separator..], + &["--", "--settings", "literal-prompt-value"] + ); + assert_eq!(std::fs::read(&source).unwrap(), original); + prepared.restore().unwrap(); +} + +#[test] +fn claude_settings_overlay_handles_inline_json_and_rejects_malformed_sources() { + let inline = vec![ + "claude".into(), + "--settings={\"model\":\"kept\",\"env\":{\"PRIVATE\":\"yes\"}}".into(), + ]; + let overlay = + crate::agents::claude::launch::settings_overlay(&inline, 0, "http://127.0.0.1:4321") + .unwrap(); + assert_eq!(overlay["model"], "kept"); + assert_eq!(overlay["env"]["PRIVATE"], "yes"); + assert_eq!( + overlay["env"]["ANTHROPIC_BASE_URL"], + "http://127.0.0.1:4321" + ); + + let after_separator = vec![ + "claude".into(), + "--".into(), + "--settings".into(), + "prompt-value".into(), + ]; + let overlay = crate::agents::claude::launch::settings_overlay( + &after_separator, + 0, + "http://127.0.0.1:4321", + ) + .unwrap(); + assert_eq!(overlay.as_object().unwrap().len(), 1); + + let missing = vec!["claude".into(), "--settings".into(), "--".into()]; + assert!( + crate::agents::claude::launch::settings_overlay(&missing, 0, "http://127.0.0.1:4321") + .unwrap_err() + .to_string() + .contains("missing its value") + ); + + let malformed_env = vec!["claude".into(), "--settings={\"env\":true}".into()]; + assert!( + crate::agents::claude::launch::settings_overlay(&malformed_env, 0, "http://127.0.0.1:4321") + .unwrap_err() + .to_string() + .contains("field `env` must be a JSON object") + ); + + let temp = tempfile::tempdir().unwrap(); + let non_object_path = temp.path().join("array-settings.json"); + std::fs::write(&non_object_path, "[]").unwrap(); + let non_object = vec![ + "claude".into(), + format!("--settings={}", non_object_path.display()), + ]; + assert!( + crate::agents::claude::launch::settings_overlay(&non_object, 0, "http://127.0.0.1:4321") + .unwrap_err() + .to_string() + .contains("must contain a JSON object") + ); + + let empty_inline = vec!["claude".into(), "--settings=".into()]; + assert!( + crate::agents::claude::launch::settings_overlay(&empty_inline, 0, "http://127.0.0.1:4321") + .unwrap_err() + .to_string() + .contains("missing its value") + ); + + let missing_file = vec![ + "claude".into(), + "--verbose".into(), + "--settings".into(), + temp.path() + .join("missing-settings.json") + .display() + .to_string(), + ]; + assert!( + crate::agents::claude::launch::settings_overlay(&missing_file, 0, "http://127.0.0.1:4321") + .unwrap_err() + .to_string() + .contains("failed to read Claude Code settings") + ); + + let malformed_json = vec!["claude".into(), "--settings={not-json".into()]; + assert!( + crate::agents::claude::launch::settings_overlay( + &malformed_json, + 0, + "http://127.0.0.1:4321" + ) + .unwrap_err() + .to_string() + .contains("failed to parse Claude Code --settings JSON") + ); +} + +#[test] +fn codex_session_hook_state_rejects_every_malformed_generated_shape() { + let malformed = [ + ( + json!({"hooks": {"SessionStart": {}}}), + "hook groups were malformed", + ), + ( + json!({"hooks": {"SessionStart": [true]}}), + "hook group was malformed", + ), + ( + json!({"hooks": {"SessionStart": [{}]}}), + "hook handlers were malformed", + ), + ( + json!({"hooks": {"SessionStart": [{"hooks": [true]}]}}), + "command hook was malformed", + ), + ( + json!({"hooks": {"SessionStart": [{"hooks": [{"type": "prompt", "command": "relay"}]}]}}), + "hook was not a command", + ), + ( + json!({"hooks": {"SessionStart": [{"hooks": [{"type": "command"}]}]}}), + "hook command was missing", + ), + ]; + for (generated, expected) in malformed { + let error = crate::agents::codex::launch::session_hook_state_override(&generated) + .unwrap_err() + .to_string(); + assert!(error.contains(expected), "{error}"); + } + + let generated = json!({ + "hooks": { + "PreToolUse": [{ + "matcher": "*", + "hooks": [{ + "type": "command", + "command": "/opt/nemo relay/bin/nemo-relay hook-forward codex", + "timeout": 0, + "statusMessage": "Forwarding to Relay" + }] + }] + } + }); + let group = generated["hooks"]["PreToolUse"][0].as_object().unwrap(); + let handler = &group["hooks"].as_array().unwrap()[0]; + let hash = + crate::agents::codex::launch::command_hook_hash("pre_tool_use", group, handler).unwrap(); + + let normalized_handler = json!({ + "type": "command", + "command": "/opt/nemo relay/bin/nemo-relay hook-forward codex", + "timeout": 1, + "statusMessage": "Forwarding to Relay" + }); + assert_eq!( + hash, + crate::agents::codex::launch::command_hook_hash("pre_tool_use", group, &normalized_handler) + .unwrap() + ); + + let mut without_matcher = group.clone(); + without_matcher.remove("matcher"); + assert_ne!( + hash, + crate::agents::codex::launch::command_hook_hash("pre_tool_use", &without_matcher, handler) + .unwrap() + ); + + let mut without_status = normalized_handler; + without_status + .as_object_mut() + .unwrap() + .remove("statusMessage"); + assert_ne!( + hash, + crate::agents::codex::launch::command_hook_hash("pre_tool_use", group, &without_status) + .unwrap() + ); + + let state = crate::agents::codex::launch::session_hook_state_override(&generated).unwrap(); + assert!(state.contains("pre_tool_use")); + assert!(state.contains("trusted_hash")); + assert!(state.contains("enabled=false")); +} + +#[test] +fn claude_prompt_named_like_the_host_does_not_capture_relay_flags() { + let resolved = ResolvedConfig { + gateway: GatewayConfig::default(), + agents: AgentConfigs::default(), + ..ResolvedConfig::default() + }; + let prepared = PreparedAgentLaunch::new( + CodingAgent::ClaudeCode, + vec!["claude".into(), "--".into(), "claude".into()], + "http://127.0.0.1:1234", + &resolved, + true, + ) + .unwrap(); + let separator = prepared.argv.iter().position(|arg| arg == "--").unwrap(); + assert_eq!(&prepared.argv[separator..], &["--", "claude"]); + assert_eq!(prepared.argv[1], "--plugin-dir"); +} + +#[test] +fn hook_write_helpers_cover_toml_escaping() { + let temp = tempfile::tempdir().unwrap(); + let written_hooks = temp.path().join("written/hooks.json"); + std::fs::create_dir_all(written_hooks.parent().unwrap()).unwrap(); + crate::agents::claude::launch::write_hooks(&written_hooks, json!({"hooks": []})).unwrap(); + assert!( + std::fs::read_to_string(&written_hooks) + .unwrap() + .contains("hooks") + ); + + let groups = crate::agents::codex::launch::hook_groups_toml(&json!([{ + "matcher": "Shell\"Run", + "hooks": [{"command": "nemo-relay \"quoted\""}] + }])); + assert!(groups.contains("matcher=\"Shell\\\"Run\"")); + assert!(groups.contains("command=\"nemo-relay \\\"quoted\\\"\"")); + + let escaped = crate::agents::codex::launch::toml_string(r#"C:\tmp\"quoted""#); + assert!(escaped.starts_with('"')); + assert!(escaped.ends_with('"')); + assert!(escaped.contains(r#"C:\\tmp\\"#)); + assert!(escaped.contains(r#"\"quoted\""#)); +} + +#[cfg(unix)] +#[test] +fn exit_code_preserves_normal_and_shell_wrapped_codes() { + let status = std::process::Command::new("/bin/sh") + .args(["-c", "exit 7"]) + .status() + .unwrap(); + assert_eq!(exit_code(status), ExitCode::from(7)); + + let status = std::process::Command::new("/bin/sh") + .args(["-c", "exit 300"]) + .status() + .unwrap(); + assert_eq!(exit_code(status), ExitCode::from(44)); +} + +// This e2e test uses Unix process exit semantics and a shell script named after the inferred host. +// Windows `.cmd` argv delivery is covered independently by `agent_process_tests`. +#[cfg(unix)] +#[tokio::test] +async fn run_starts_gateway_injects_env_and_returns_agent_exit_code() { + let temp = tempfile::tempdir().unwrap(); + let config = temp.path().join("config.toml"); + std::fs::write(&config, "[upstream]\n").unwrap(); + let output = temp.path().join("env.txt"); + let command_argv = fake_agent_command(temp.path(), &output); + let command = RunOverrides { + // Leave `agent: None` so the launcher infers from argv[0] and uses `command_argv` + // (our fake-agent.sh) as the full argv. With --agent set, the resolver appends + // command as pass-through after the configured/default binary — not what this test + // wants, since it specifically asserts that argv[0] is the fake script. + agent: None, + config: Some(config), + openai_base_url: None, + anthropic_base_url: None, + session_metadata: None, + plugin_config_path: None, + dry_run: false, + print: false, + command: command_argv, + }; + + let code = run(command, None).await.unwrap(); + + assert_eq!(code, ExitCode::from(7)); + let url = std::fs::read_to_string(output).unwrap(); + assert!(url.starts_with("http://127.0.0.1:")); + assert!(!url.ends_with(":0")); +} + +#[cfg(unix)] +fn fake_agent_command(temp: &Path, output: &Path) -> Vec { + // Name the script `codex` (not `fake-agent.sh`) so `CodingAgent::infer` recognizes the + // argv[0] basename without us needing to set `--agent` explicitly. With `--agent` set, + // the resolver appends `command.command` as pass-through args after the configured/default + // binary — wrong for this test, which wants the fake script itself to be argv[0]. + let script = temp.join("codex"); + std::fs::write( + &script, + format!( + "#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then\n echo 'codex-cli 0.143.0'\n exit 0\nfi\nprintf '%s' \"$NEMO_RELAY_GATEWAY_URL\" > \"{}\"\nexit 7\n", + output.display() + ), + ) + .unwrap(); + make_executable(&script); + vec![script.display().to_string()] +} + +#[tokio::test] +async fn dry_run_does_not_spawn_agent() { + let command = RunOverrides { + agent: None, + config: None, + openai_base_url: None, + anthropic_base_url: None, + session_metadata: None, + plugin_config_path: None, + dry_run: true, + print: false, + command: vec!["/path/that/does/not/exist/codex".into()], + }; + + let code = run(command, None).await.unwrap(); + + assert_eq!(code, ExitCode::SUCCESS); +} + +#[tokio::test] +async fn dry_run_does_not_hydrate_dynamic_plugin_lifecycle_state() { + let temp = tempfile::tempdir().unwrap(); + let plugin_dir = temp.path().join("plugins/acme"); + std::fs::create_dir_all(&plugin_dir).unwrap(); + let manifest_path = plugin_dir.join("relay-plugin.toml"); + std::fs::write( + &manifest_path, + format!( + r#" +manifest_version = 1 + +[plugin] +id = "acme.worker" +kind = "worker" + +[compat] +relay = "={version}" +worker_protocol = "grpc-v1" + +[capabilities] +items = ["plugin_worker"] + +[defaults] + +[load] +runtime = "python" +entrypoint = "acme.worker:create_plugin" +"#, + version = env!("CARGO_PKG_VERSION"), + ), + ) + .unwrap(); + let config_path = temp.path().join("config.toml"); + std::fs::write(&config_path, "").unwrap(); + std::fs::write( + temp.path().join("plugins.toml"), + format!( + "[[plugins.dynamic]]\nmanifest = {:?}\n", + manifest_path.to_string_lossy() + ), + ) + .unwrap(); + + let command = RunOverrides { + agent: Some(CodingAgent::Codex), + config: Some(config_path), + openai_base_url: None, + anthropic_base_url: None, + session_metadata: None, + plugin_config_path: None, + dry_run: true, + print: false, + command: vec!["codex".into()], + }; + + let code = run(command, None).await.unwrap(); + + assert_eq!(code, ExitCode::SUCCESS); + assert!(!temp.path().join(".dynamic-plugins.json").exists()); +} + +#[tokio::test] +async fn wait_for_health_reports_unready_gateway() { + let error = wait_for_health("http://127.0.0.1:1", "test-fingerprint") + .await + .unwrap_err() + .to_string(); + + assert!(error.contains("gateway did not become ready"), "{error}"); +} + +#[cfg(unix)] +#[tokio::test] +async fn gateway_failure_terminates_the_agent_and_restores_private_state() { + let temp = tempfile::tempdir().unwrap(); + let wrapper_pid_path = temp.path().join("wrapper.pid"); + let descendant_pid_path = temp.path().join("descendant.pid"); + let script = temp.path().join("test-agent"); + std::fs::write( + &script, + "#!/bin/sh\necho $$ > \"$1\"\nsh -c 'echo $$ > \"$1\"; while :; do :; done' descendant \"$2\" &\nwait \"$!\"\n", + ) + .unwrap(); + make_executable(&script); + let overlay = temp.path().join("private-overlay"); + std::fs::create_dir_all(&overlay).unwrap(); + let prepared = PreparedAgentLaunch { + argv: vec![ + script.display().to_string(), + wrapper_pid_path.display().to_string(), + descendant_pid_path.display().to_string(), + ], + host_index: 0, + env: Vec::new(), + temp_dirs: vec![overlay.clone()], + notes: Vec::new(), + }; + let observed_wrapper_pid_path = wrapper_pid_path.clone(); + let observed_descendant_pid_path = descendant_pid_path.clone(); + let task = tokio::spawn(async move { + for _ in 0..500 { + if observed_wrapper_pid_path.exists() && observed_descendant_pid_path.exists() { + return Err(CliError::Launch("injected gateway failure".into())); + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + Err(CliError::Launch( + "test agent did not publish its process ID".into(), + )) + }); + let (shutdown_tx, _shutdown_rx) = oneshot::channel(); + let running_server = RunningGateway { shutdown_tx, task }; + + let error = tokio::time::timeout( + Duration::from_secs(10), + supervise_prepared_run(&prepared, running_server), + ) + .await + .expect("agent supervision did not finish") + .unwrap_err() + .to_string(); + + assert!(error.contains("injected gateway failure"), "{error}"); + assert!(!overlay.exists()); + for pid_path in [wrapper_pid_path, descendant_pid_path] { + let pid = std::fs::read_to_string(pid_path).unwrap(); + let deadline = std::time::Instant::now() + Duration::from_secs(5); + loop { + // SAFETY: Signal 0 performs an existence check and does not alter the target process. + let result = unsafe { libc::kill(pid.trim().parse().unwrap(), 0) }; + if result == -1 && std::io::Error::last_os_error().raw_os_error() == Some(libc::ESRCH) { + break; + } + assert!( + std::time::Instant::now() < deadline, + "agent process {pid} was not reaped" + ); + tokio::time::sleep(Duration::from_millis(20)).await; + } + } +} + +#[tokio::test] +async fn execute_live_run_reports_gateway_startup_error_when_health_check_fails() { + let _guard = crate::test_support::PLUGIN_CONFIG_TEST_LOCK.lock().await; + let _env = EnvScope::without_managed_bootstrap(); + let _ = nemo_relay::plugin::clear_plugin_configuration(); + let resolved = ResolvedConfig { + gateway: GatewayConfig::default(), + agents: AgentConfigs::default(), + ..ResolvedConfig::default() + }; + let prepared = PreparedAgentLaunch::new( + CodingAgent::ClaudeCode, + vec!["claude".into()], + "http://127.0.0.1:1234", + &resolved, + false, + ) + .unwrap(); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let gateway_url = format!("http://{}", listener.local_addr().unwrap()); + let gateway_config = GatewayConfig { + plugin_config: Some(json!({ + "version": 1, + "components": [{ + "kind": OBSERVABILITY_PLUGIN_KIND, + "enabled": true, + "config": { + "version": 1, + "atof": { + "enabled": true, + "mode": "invalid" + } + } + }] + })), + ..GatewayConfig::default() + }; + + let error = execute_live_run(listener, gateway_config, &gateway_url, prepared) + .await + .unwrap_err() + .to_string(); + + assert!(error.contains("ATOF mode")); + assert!(!error.contains("gateway did not become ready")); +} + +#[tokio::test] +async fn execute_live_run_removes_hermes_overlay_when_health_check_fails() { + let _guard = crate::test_support::PLUGIN_CONFIG_TEST_LOCK.lock().await; + let _env = EnvScope::without_managed_bootstrap(); + let _ = nemo_relay::plugin::clear_plugin_configuration(); + let temp = tempfile::tempdir().unwrap(); + let hooks_path = temp.path().join("hermes-home/config.yaml"); + std::fs::create_dir_all(hooks_path.parent().unwrap()).unwrap(); + let original = "hooks:\n PreToolUse: []\n"; + std::fs::write(&hooks_path, original).unwrap(); + let resolved = ResolvedConfig { + gateway: GatewayConfig::default(), + agents: AgentConfigs { + hermes: AgentCommandConfig { + command: None, + hooks_path: Some(hooks_path.clone()), + }, + ..AgentConfigs::default() + }, + ..ResolvedConfig::default() + }; + let prepared = PreparedAgentLaunch::new( + CodingAgent::Hermes, + vec!["hermes".into(), "chat".into()], + "http://127.0.0.1:1234", + &resolved, + false, + ) + .unwrap(); + let overlay = prepared + .env + .iter() + .find_map(|(name, value)| (name == "HERMES_HOME").then(|| PathBuf::from(value))) + .unwrap(); + let overlay_config: serde_json::Value = + serde_yaml::from_str(&std::fs::read_to_string(overlay.join("config.yaml")).unwrap()) + .unwrap(); + assert!(crate::hook_assertions::value_has_command_arguments( + &overlay_config, + &[ + "hook-forward", + "hermes", + "--gateway-url", + "http://127.0.0.1:1234", + "--transparent-run", + ], + )); + + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let error = execute_live_run( + listener, + GatewayConfig::default(), + "http://127.0.0.1:1", + prepared, + ) + .await + .unwrap_err() + .to_string(); + + assert!(error.contains("gateway did not become ready"), "{error}"); + assert_eq!(std::fs::read_to_string(&hooks_path).unwrap(), original); + assert!(!overlay.exists()); +} + +#[cfg(unix)] +fn make_executable(path: &Path) { + use std::os::unix::fs::PermissionsExt; + let mut permissions = std::fs::metadata(path).unwrap().permissions(); + permissions.set_mode(0o755); + std::fs::set_permissions(path, permissions).unwrap(); +} diff --git a/crates/cli/tests/coverage/agents/plugin_host_tests.rs b/crates/cli/tests/coverage/agents/plugin_host_tests.rs new file mode 100644 index 000000000..c9e4233c4 --- /dev/null +++ b/crates/cli/tests/coverage/agents/plugin_host_tests.rs @@ -0,0 +1,3761 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use std::collections::{BTreeSet, VecDeque}; +#[cfg(windows)] +use std::ffi::OsString; +use std::fs; +use std::io::{Read, Write}; +use std::net::TcpListener; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; +use std::thread; +use std::time::Duration; + +use serde_json::{Value, json}; +use tempfile::tempdir; +use toml_edit::{DocumentMut, Item, Value as TomlValue}; + +use super::*; +use crate::configuration::{BOOTSTRAP_CLIENT_TOKEN_HEADER, BootstrapChallengeKey}; +use crate::filesystem::{backup, backup_path, restore_file_snapshot, snapshot_optional_file}; + +const TEST_PLUGIN_GENERATION: &str = "test-generation"; + +#[derive(Default)] +struct FakeCodexHooksClient { + hook_lists: VecDeque, String>>, + trusted: Vec>, + cleared: Vec>, + restored: Vec)>>, + clear_config_path: Option, + trust_error: Option, + clear_error: Option, + restore_error: Option, +} + +impl CodexHooksClient for FakeCodexHooksClient { + fn list_hooks(&mut self, _cwd: &std::path::Path) -> Result, String> { + self.hook_lists + .pop_front() + .unwrap_or_else(|| Err("unexpected hooks/list call".into())) + } + + fn trust_hooks(&mut self, hooks: &[CodexHookMetadata]) -> Result<(), String> { + self.trusted + .push(hooks.iter().map(|hook| hook.key.clone()).collect()); + match self.trust_error.take() { + Some(error) => Err(error), + None => Ok(()), + } + } + + fn clear_hook_trust(&mut self, keys: &[String]) -> Result<(), String> { + self.cleared.push(keys.to_vec()); + if let Some(error) = self.clear_error.take() { + return Err(error); + } + if let Some(path) = &self.clear_config_path { + let raw = fs::read_to_string(path) + .map_err(|error| format!("failed to read {}: {error}", path.display()))?; + let mut config = raw + .parse::() + .map_err(|error| format!("invalid TOML in {}: {error}", path.display()))?; + if let Some(state) = config + .get_mut("hooks") + .and_then(Item::as_table_mut) + .and_then(|hooks| hooks.get_mut("state")) + .and_then(Item::as_table_mut) + { + for key in keys { + state.remove(key); + } + } + fs::write(path, config.to_string()) + .map_err(|error| format!("failed to write {}: {error}", path.display()))?; + } + Ok(()) + } + + fn restore_hook_trust(&mut self, state: &[(String, Option)]) -> Result<(), String> { + self.restored.push(state.to_vec()); + match self.restore_error.take() { + Some(error) => Err(error), + None => Ok(()), + } + } +} + +fn expected_plugin_command() -> String { + let relay = current_exe().unwrap(); + let relay = relay.canonicalize().unwrap_or(relay); + let relay = portable_executable_path(relay); + codex_plugin_hook_command( + &relay, + Path::new("/tmp/nemo-relay-plugin/.nemo-relay-generation"), + TEST_PLUGIN_GENERATION, + ) + .unwrap() +} + +fn write_plugin_generation_for_hooks(path: &Path) { + let plugin_root = path.parent().and_then(Path::parent).unwrap(); + fs::create_dir_all(plugin_root).unwrap(); + fs::write( + plugin_root.join(crate::installation::generation::GENERATION_FILE_NAME), + format!("{TEST_PLUGIN_GENERATION}\n"), + ) + .unwrap(); +} + +fn expected_plugin_command_for_hooks(path: &Path) -> String { + fs::read_to_string(path) + .ok() + .and_then(|raw| serde_json::from_str::(&raw).ok()) + .and_then(|value| { + value + .get("hooks")? + .as_object()? + .values() + .next()? + .as_array()? + .first()? + .get("hooks")? + .as_array()? + .first()? + .get("command")? + .as_str() + .map(str::to_owned) + }) + .unwrap_or_else(expected_plugin_command) +} + +fn empty_codex_hooks_client() -> FakeCodexHooksClient { + FakeCodexHooksClient { + hook_lists: VecDeque::from([Ok(Vec::new())]), + ..FakeCodexHooksClient::default() + } +} + +fn write_plugin_hooks(plugin_root: &Path) -> PathBuf { + let path = plugin_root.join("hooks").join("hooks.json"); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + write_plugin_generation_for_hooks(&path); + fs::write( + &path, + serde_json::to_vec_pretty(&generated_hooks( + CodingAgent::Codex, + &expected_plugin_hook_command(&path).unwrap(), + )) + .unwrap(), + ) + .unwrap(); + path +} + +fn codex_hook_metadata( + hooks_path: &std::path::Path, + event_name: &str, + key: &str, + trust_status: &str, + enabled: bool, +) -> CodexHookMetadata { + let hooks_path = if hooks_path.is_dir() { + hooks_path.join("hooks.json") + } else { + hooks_path.to_path_buf() + }; + write_plugin_generation_for_hooks(&hooks_path); + if !hooks_path.exists() { + fs::create_dir_all(hooks_path.parent().unwrap()).unwrap(); + fs::write( + &hooks_path, + serde_json::to_vec_pretty(&generated_hooks( + CodingAgent::Codex, + &expected_plugin_command_for_hooks(&hooks_path), + )) + .unwrap(), + ) + .unwrap(); + } + CodexHookMetadata { + key: key.into(), + event_name: event_name.into(), + handler_type: "command".into(), + command: Some(expected_plugin_command_for_hooks(&hooks_path)), + source_path: hooks_path.display().to_string(), + source: "plugin".into(), + plugin_id: Some(CODEX_PLUGIN_ID.into()), + enabled, + current_hash: format!("sha256:{key}"), + trust_status: trust_status.into(), + } +} + +fn required_codex_hook_metadata( + hooks_path: &std::path::Path, + trust_status: &str, + enabled: bool, +) -> Vec { + generated_codex_hook_metadata(hooks_path, trust_status, enabled) +} + +fn generated_codex_hook_metadata( + hooks_path: &std::path::Path, + trust_status: &str, + enabled: bool, +) -> Vec { + [ + "session_start", + "user_prompt_submit", + "pre_tool_use", + "post_tool_use", + "permission_request", + "subagent_start", + "subagent_stop", + "stop", + "pre_compact", + "post_compact", + ] + .into_iter() + .enumerate() + .map(|(index, event)| { + codex_hook_metadata( + hooks_path, + event, + &format!("relay-hook-{index}"), + trust_status, + enabled, + ) + }) + .collect() +} + +fn persisted_relay_hook_key(event: &str, index: usize) -> String { + format!("{CODEX_PLUGIN_HOOK_KEY_PREFIX}{event}:0:{index}") +} + +fn persisted_relay_hook_metadata(hooks_path: &Path, trust_status: &str) -> Vec { + let mut hooks = generated_codex_hook_metadata(hooks_path, trust_status, true) + .into_iter() + .enumerate() + .map(|(index, mut hook)| { + hook.key = persisted_relay_hook_key(&hook.event_name, index); + hook + }) + .collect::>(); + hooks.extend( + ["post_tool_use_failure", "notification", "session_end"] + .into_iter() + .enumerate() + .map(|(offset, event)| { + codex_hook_metadata( + hooks_path, + event, + &persisted_relay_hook_key(event, 10 + offset), + trust_status, + true, + ) + }), + ); + hooks +} + +fn write_persisted_hook_trust(config_path: &Path, keys: &[String], unrelated_key: &str) { + let mut raw = "model_provider = \"openai\"\n".to_string(); + for key in keys { + raw.push_str(&format!( + "\n[hooks.state.{key:?}]\ntrusted_hash = {hash:?}\nenabled = true\n", + hash = format!("sha256:{key}") + )); + } + raw.push_str(&format!( + "\n[hooks.state.{unrelated_key:?}]\ntrusted_hash = {hash:?}\nenabled = true\n", + hash = format!("sha256:{unrelated_key}") + )); + fs::write(config_path, raw).unwrap(); +} + +#[cfg(not(windows))] +fn fake_codex_app_server( + dir: &std::path::Path, + hooks: &[CodexHookMetadata], +) -> (EnvVarGuard, EnvVarGuard, EnvVarGuard) { + use std::os::unix::fs::PermissionsExt; + + let bin_dir = dir.join("fake-codex-bin"); + fs::create_dir_all(&bin_dir).unwrap(); + let codex = bin_dir.join("codex"); + fs::write( + &codex, + r#"#!/bin/sh +while IFS= read -r line; do + printf '%s\n' "$line" >> "$NEMO_RELAY_TEST_CODEX_LOG" + id=$(printf '%s\n' "$line" | sed -E 's/.*"id":([0-9]+).*/\1/') + case "$line" in + *'"method":"initialize"'*) + printf '{"id":%s,"result":{}}\n' "$id" + ;; + *'"method":"hooks/list"'*) + printf '{"id":%s,"result":{"data":[{"cwd":"/tmp","hooks":%s,"warnings":[],"errors":[]}]}}\n' "$id" "$NEMO_RELAY_TEST_CODEX_HOOKS" + ;; + *'"method":"config/batchWrite"'*) + printf '{"id":%s,"result":{}}\n' "$id" + ;; + esac +done +"#, + ) + .unwrap(); + let mut permissions = fs::metadata(&codex).unwrap().permissions(); + permissions.set_mode(0o755); + fs::set_permissions(&codex, permissions).unwrap(); + let existing_path = std::env::var_os("PATH").unwrap_or_default(); + let mut paths = vec![bin_dir]; + paths.extend(std::env::split_paths(&existing_path)); + let path = std::env::join_paths(paths).unwrap(); + let log_path = dir.join("fake-codex-requests.jsonl"); + fs::write(&log_path, "").unwrap(); + ( + EnvVarGuard::set_value("PATH", &path.to_string_lossy()), + EnvVarGuard::set_value( + "NEMO_RELAY_TEST_CODEX_HOOKS", + &serde_json::to_string(hooks).unwrap(), + ), + EnvVarGuard::set_value("NEMO_RELAY_TEST_CODEX_LOG", &log_path.to_string_lossy()), + ) +} + +fn read_http_request(stream: &mut std::net::TcpStream) -> Vec { + stream + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + let mut request = Vec::new(); + let mut buffer = [0_u8; 1024]; + loop { + match stream.read(&mut buffer) { + Ok(0) => break, + Ok(count) => { + request.extend_from_slice(&buffer[..count]); + if http_request_body_complete(&request) { + break; + } + } + Err(error) + if matches!( + error.kind(), + std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut + ) => + { + break; + } + Err(error) => panic!("failed to read local HTTP request: {error}"), + } + } + request +} + +fn http_request_body_complete(request: &[u8]) -> bool { + let Some(header_end) = request.windows(4).position(|window| window == b"\r\n\r\n") else { + return false; + }; + let body_start = header_end + 4; + let headers = String::from_utf8_lossy(&request[..body_start]); + let content_length = headers + .lines() + .find_map(|line| { + let (name, value) = line.split_once(':')?; + name.eq_ignore_ascii_case("content-length") + .then(|| value.trim().parse::().ok()) + .flatten() + }) + .unwrap_or(0); + request.len() >= body_start + content_length +} + +fn home_env_lock() -> &'static Mutex<()> { + &crate::test_support::ENV_TEST_LOCK +} + +struct HomeScope<'a> { + _guard: std::sync::MutexGuard<'a, ()>, + prev_home: Option, + prev_userprofile: Option, + prev_codex_home: Option, + prev_xdg_config_home: Option, +} + +impl<'a> HomeScope<'a> { + fn enter(path: &std::path::Path) -> Self { + let guard = home_env_lock() + .lock() + .unwrap_or_else(|error| error.into_inner()); + let prev_home = std::env::var_os("HOME"); + let prev_userprofile = std::env::var_os("USERPROFILE"); + let prev_codex_home = std::env::var_os("CODEX_HOME"); + let prev_xdg_config_home = std::env::var_os("XDG_CONFIG_HOME"); + // SAFETY: This test holds a process-wide mutex for the lifetime of the env override. + unsafe { + std::env::set_var("HOME", path); + std::env::remove_var("USERPROFILE"); + std::env::remove_var("CODEX_HOME"); + std::env::set_var("XDG_CONFIG_HOME", path.join(".config")); + } + Self { + _guard: guard, + prev_home, + prev_userprofile, + prev_codex_home, + prev_xdg_config_home, + } + } +} + +impl<'a> Drop for HomeScope<'a> { + fn drop(&mut self) { + // SAFETY: This restores the process environment while the mutex is still held. + unsafe { + match self.prev_home.take() { + Some(value) => std::env::set_var("HOME", value), + None => std::env::remove_var("HOME"), + } + match self.prev_userprofile.take() { + Some(value) => std::env::set_var("USERPROFILE", value), + None => std::env::remove_var("USERPROFILE"), + } + match self.prev_codex_home.take() { + Some(value) => std::env::set_var("CODEX_HOME", value), + None => std::env::remove_var("CODEX_HOME"), + } + match self.prev_xdg_config_home.take() { + Some(value) => std::env::set_var("XDG_CONFIG_HOME", value), + None => std::env::remove_var("XDG_CONFIG_HOME"), + } + } + } +} + +struct EnvVarGuard { + key: &'static str, + previous: Option, +} + +impl EnvVarGuard { + fn set_path(key: &'static str, value: &std::path::Path) -> Self { + let previous = std::env::var_os(key); + // SAFETY: Callers hold the process-wide environment mutex through HomeScope. + unsafe { + std::env::set_var(key, value); + } + Self { key, previous } + } + + #[cfg(unix)] + fn set_value(key: &'static str, value: &str) -> Self { + let previous = std::env::var_os(key); + // SAFETY: Callers hold the process-wide environment mutex through HomeScope. + unsafe { + std::env::set_var(key, value); + } + Self { key, previous } + } + + fn remove(key: &'static str) -> Self { + let previous = std::env::var_os(key); + // SAFETY: Callers hold the process-wide environment mutex through HomeScope. + unsafe { + std::env::remove_var(key); + } + Self { key, previous } + } +} + +impl Drop for EnvVarGuard { + fn drop(&mut self) { + // SAFETY: This restores the process environment while HomeScope still holds the mutex. + unsafe { + match self.previous.take() { + Some(value) => std::env::set_var(self.key, value), + None => std::env::remove_var(self.key), + } + } + } +} + +#[test] +fn backup_preserves_first_snapshot() { + let dir = tempdir().unwrap(); + let path = dir.path().join("config.toml"); + fs::write(&path, "model_provider = \"openai\"\n").unwrap(); + + backup(&path).unwrap(); + fs::write(&path, "model_provider = \"nemo-relay-openai\"\n").unwrap(); + backup(&path).unwrap(); + + assert_eq!( + fs::read_to_string(backup_path(&path)).unwrap(), + "model_provider = \"openai\"\n" + ); +} + +#[test] +fn atomic_write_replaces_existing_destination() { + let dir = tempdir().unwrap(); + let path = dir.path().join("config.toml"); + fs::write(&path, "old\n").unwrap(); + + atomic_write(&path, b"new\n").unwrap(); + + assert_eq!(fs::read_to_string(&path).unwrap(), "new\n"); +} + +#[test] +fn codex_auto_trusts_only_exact_generated_plugin_hooks_and_verifies_them() { + let dir = tempdir().unwrap(); + let hooks_path = dir.path().join("plugin").join("hooks.json"); + let config_path = dir.path().join(".codex").join("config.toml"); + fs::create_dir_all(config_path.parent().unwrap()).unwrap(); + fs::write(&config_path, "model = \"test\"\n").unwrap(); + let initial = generated_codex_hook_metadata(&hooks_path, "untrusted", true); + let verified = generated_codex_hook_metadata(&hooks_path, "trusted", true); + let mut decoys = Vec::new(); + let mut wrong_command = codex_hook_metadata( + &hooks_path, + "session_start", + "wrong-command", + "untrusted", + true, + ); + wrong_command.command = Some("custom hook".into()); + decoys.push(wrong_command); + let mut wrong_source = codex_hook_metadata( + &hooks_path, + "session_start", + "wrong-source", + "untrusted", + true, + ); + wrong_source.source = "project".into(); + decoys.push(wrong_source); + let mut wrong_plugin = codex_hook_metadata( + &hooks_path, + "session_start", + "wrong-plugin", + "untrusted", + true, + ); + wrong_plugin.plugin_id = Some("another-plugin@example".into()); + decoys.push(wrong_plugin); + let mut client = FakeCodexHooksClient { + hook_lists: VecDeque::from([ + Ok(initial.iter().cloned().chain(decoys).collect()), + Ok(verified), + ]), + ..FakeCodexHooksClient::default() + }; + + auto_trust_codex_hooks( + &mut client, + dir.path(), + &config_path, + &expected_plugin_command(), + ) + .unwrap(); + + assert_eq!( + client.trusted, + vec![ + (0..10) + .map(|index| format!("relay-hook-{index}")) + .collect::>() + ] + ); +} + +#[test] +fn codex_auto_trust_refuses_missing_required_hook_without_writing_state() { + let dir = tempdir().unwrap(); + let hooks_path = dir.path().join("plugin").join("hooks.json"); + let config_path = dir.path().join(".codex").join("config.toml"); + fs::create_dir_all(config_path.parent().unwrap()).unwrap(); + fs::write(&config_path, "model = \"test\"\n").unwrap(); + let mut hooks = required_codex_hook_metadata(&hooks_path, "untrusted", true); + hooks.retain(|hook| hook.event_name != "stop"); + let mut client = FakeCodexHooksClient { + hook_lists: VecDeque::from([Ok(hooks)]), + ..FakeCodexHooksClient::default() + }; + + let error = auto_trust_codex_hooks( + &mut client, + dir.path(), + &config_path, + &expected_plugin_command(), + ) + .unwrap_err(); + + assert!(error.contains("Stop")); + assert!(client.trusted.is_empty()); +} + +#[test] +fn codex_auto_trust_refuses_duplicate_discovered_handler() { + let dir = tempdir().unwrap(); + let config_path = dir.path().join("config.toml"); + fs::write(&config_path, "").unwrap(); + let mut hooks = generated_codex_hook_metadata(dir.path(), "untrusted", true); + let mut duplicate = hooks.last().unwrap().clone(); + duplicate.key = "duplicate-post-compact".into(); + hooks.push(duplicate); + let mut client = FakeCodexHooksClient { + hook_lists: VecDeque::from([Ok(hooks)]), + ..FakeCodexHooksClient::default() + }; + + let error = auto_trust_codex_hooks( + &mut client, + dir.path(), + &config_path, + &expected_plugin_command(), + ) + .unwrap_err(); + + assert!(error.contains("duplicate: PostCompact"), "{error}"); + assert!(client.trusted.is_empty()); +} + +#[test] +fn every_generated_codex_hook_is_required_exactly_once_and_trusted() { + for (event, display) in [ + ("session_start", "SessionStart"), + ("user_prompt_submit", "UserPromptSubmit"), + ("pre_tool_use", "PreToolUse"), + ("post_tool_use", "PostToolUse"), + ("permission_request", "PermissionRequest"), + ("subagent_start", "SubagentStart"), + ("subagent_stop", "SubagentStop"), + ("stop", "Stop"), + ("pre_compact", "PreCompact"), + ("post_compact", "PostCompact"), + ] { + for condition in ["missing", "duplicate", "disabled", "modified"] { + let dir = tempdir().unwrap(); + let mut hooks = generated_codex_hook_metadata(dir.path(), "trusted", true); + let target = hooks + .iter() + .position(|hook| hook.event_name == event) + .unwrap(); + let target_key = hooks[target].key.clone(); + match condition { + "missing" => { + hooks.remove(target); + } + "duplicate" => { + let mut duplicate = hooks[target].clone(); + duplicate.key = format!("duplicate-{event}"); + hooks.push(duplicate); + } + "disabled" => hooks[target].enabled = false, + "modified" => hooks[target].trust_status = "modified".into(), + _ => unreachable!(), + } + + let report = codex_hook_trust_report_for(&hooks); + let json = report.to_json(); + assert!( + !report.ready(), + "{event} unexpectedly ready while {condition}" + ); + match condition { + "missing" => assert!( + json["missing_required"] + .as_array() + .unwrap() + .iter() + .any(|value| value == display), + "{json}" + ), + "duplicate" => assert!( + json["duplicate_required"] + .as_array() + .unwrap() + .iter() + .any(|value| value == display), + "{json}" + ), + "disabled" => assert!( + json["disabled"] + .as_array() + .unwrap() + .iter() + .any(|value| value == &target_key), + "{json}" + ), + "modified" => assert!( + json["modified"] + .as_array() + .unwrap() + .iter() + .any(|value| value == &target_key), + "{json}" + ), + _ => unreachable!(), + } + } + } +} + +#[test] +fn codex_auto_trust_reverifies_every_generated_hook_after_writing() { + for event in [ + "session_start", + "user_prompt_submit", + "pre_tool_use", + "post_tool_use", + "permission_request", + "subagent_start", + "subagent_stop", + "stop", + "pre_compact", + "post_compact", + ] { + for condition in ["missing", "duplicate", "disabled", "modified"] { + let dir = tempdir().unwrap(); + let config_path = dir.path().join("config.toml"); + fs::write(&config_path, "").unwrap(); + let initial = generated_codex_hook_metadata(dir.path(), "untrusted", true); + let mut verified = generated_codex_hook_metadata(dir.path(), "trusted", true); + let target = verified + .iter() + .position(|hook| hook.event_name == event) + .unwrap(); + match condition { + "missing" => { + verified.remove(target); + } + "duplicate" => { + let mut duplicate = verified[target].clone(); + duplicate.key = format!("duplicate-{event}"); + verified.push(duplicate); + } + "disabled" => verified[target].enabled = false, + "modified" => verified[target].trust_status = "modified".into(), + _ => unreachable!(), + } + let mut client = FakeCodexHooksClient { + hook_lists: VecDeque::from([ + Ok(initial.clone()), + Ok(verified), + Ok(initial.clone()), + ]), + ..FakeCodexHooksClient::default() + }; + + let error = auto_trust_codex_hooks( + &mut client, + dir.path(), + &config_path, + &expected_plugin_command(), + ) + .unwrap_err(); + + assert!( + error.contains("did not enable and trust"), + "{event} {condition}: {error}" + ); + assert_eq!(client.restored.len(), 1, "{event} {condition}"); + assert_eq!(client.restored[0].len(), 10, "{event} {condition}"); + } + } +} + +#[test] +fn codex_auto_trust_rejects_targeted_hook_that_disappears_after_write() { + let dir = tempdir().unwrap(); + let config_path = dir.path().join("config.toml"); + fs::write(&config_path, "").unwrap(); + let initial = generated_codex_hook_metadata(dir.path(), "untrusted", true); + let mut verified = initial.clone(); + for hook in &mut verified { + hook.trust_status = "trusted".into(); + } + verified.retain(|hook| hook.event_name != "post_compact"); + let mut client = FakeCodexHooksClient { + hook_lists: VecDeque::from([Ok(initial.clone()), Ok(verified), Ok(initial.clone())]), + ..FakeCodexHooksClient::default() + }; + + let error = auto_trust_codex_hooks( + &mut client, + dir.path(), + &config_path, + &expected_plugin_command(), + ) + .unwrap_err(); + + assert!( + error.contains("unverified targeted hooks=relay-hook-9"), + "{error}" + ); + assert_eq!(client.restored.len(), 1); + assert_eq!(client.restored[0].len(), initial.len()); +} + +#[test] +fn codex_auto_trust_rejects_targeted_hook_that_changes_key_after_write() { + let dir = tempdir().unwrap(); + let config_path = dir.path().join("config.toml"); + fs::write(&config_path, "").unwrap(); + let initial = generated_codex_hook_metadata(dir.path(), "untrusted", true); + let mut verified = initial.clone(); + for hook in &mut verified { + hook.trust_status = "trusted".into(); + } + verified.last_mut().unwrap().key = "replacement-post-compact".into(); + let mut client = FakeCodexHooksClient { + hook_lists: VecDeque::from([Ok(initial.clone()), Ok(verified), Ok(initial.clone())]), + ..FakeCodexHooksClient::default() + }; + + let error = auto_trust_codex_hooks( + &mut client, + dir.path(), + &config_path, + &expected_plugin_command(), + ) + .unwrap_err(); + + assert!( + error.contains("unverified targeted hooks=relay-hook-9"), + "{error}" + ); + assert_eq!(client.restored.len(), 1); +} + +#[test] +fn codex_auto_trust_restores_exact_prior_state_after_verification_failure() { + let dir = tempdir().unwrap(); + let config_path = dir.path().join("config.toml"); + fs::write( + &config_path, + r#" +[hooks.state."relay-hook-0"] +trusted_hash = "sha256:original" +enabled = false +custom = "preserve" +"#, + ) + .unwrap(); + let initial = generated_codex_hook_metadata(dir.path(), "untrusted", true); + let mut failed_verification = generated_codex_hook_metadata(dir.path(), "trusted", true); + failed_verification[9].enabled = false; + let mut client = FakeCodexHooksClient { + hook_lists: VecDeque::from([Ok(initial.clone()), Ok(failed_verification), Ok(initial)]), + ..FakeCodexHooksClient::default() + }; + + let error = auto_trust_codex_hooks( + &mut client, + dir.path(), + &config_path, + &expected_plugin_command(), + ) + .unwrap_err(); + + assert!(error.contains("did not enable and trust"), "{error}"); + assert_eq!(client.restored.len(), 1); + assert_eq!(client.restored[0].len(), 10); + assert_eq!( + client.restored[0] + .iter() + .find(|(key, _)| key == "relay-hook-0") + .unwrap() + .1, + Some(json!({ + "trusted_hash": "sha256:original", + "enabled": false, + "custom": "preserve" + })) + ); + assert!( + client.restored[0] + .iter() + .filter(|(key, _)| key != "relay-hook-0") + .all(|(_, value)| value.is_none()) + ); +} + +#[test] +fn codex_auto_trust_aggregates_original_and_rollback_errors() { + let dir = tempdir().unwrap(); + let config_path = dir.path().join("config.toml"); + fs::write(&config_path, "").unwrap(); + let initial = required_codex_hook_metadata(dir.path(), "untrusted", true); + let mut client = FakeCodexHooksClient { + hook_lists: VecDeque::from([Ok(initial)]), + trust_error: Some("trust write failed".into()), + restore_error: Some("trust restore failed".into()), + ..FakeCodexHooksClient::default() + }; + + let error = auto_trust_codex_hooks( + &mut client, + dir.path(), + &config_path, + &expected_plugin_command(), + ) + .unwrap_err(); + + assert!(error.contains("trust write failed"), "{error}"); + assert!(error.contains("trust restore failed"), "{error}"); +} + +#[test] +fn codex_auto_trust_does_not_depend_on_reported_plugin_source_path() { + let dir = tempdir().unwrap(); + let reported_hooks_path = dir.path().join("codex-cache").join("hooks.json"); + let config_path = dir.path().join(".codex").join("config.toml"); + fs::create_dir_all(config_path.parent().unwrap()).unwrap(); + fs::write(&config_path, "").unwrap(); + let initial = required_codex_hook_metadata(&reported_hooks_path, "untrusted", true); + let verified = required_codex_hook_metadata(&reported_hooks_path, "trusted", true); + let mut client = FakeCodexHooksClient { + hook_lists: VecDeque::from([Ok(initial), Ok(verified)]), + ..FakeCodexHooksClient::default() + }; + + auto_trust_codex_hooks( + &mut client, + dir.path(), + &config_path, + &expected_plugin_command(), + ) + .unwrap(); + + assert_eq!(client.trusted[0].len(), 10); +} + +#[test] +fn codex_auto_trust_rejects_modified_loaded_plugin_hook_file() { + let dir = tempdir().unwrap(); + let reported_hooks_path = dir.path().join("codex-cache").join("hooks.json"); + fs::create_dir_all(reported_hooks_path.parent().unwrap()).unwrap(); + fs::write( + &reported_hooks_path, + serde_json::to_vec_pretty(&generated_hooks(CodingAgent::Codex, "malicious-command")) + .unwrap(), + ) + .unwrap(); + let config_path = dir.path().join("config.toml"); + fs::write(&config_path, "").unwrap(); + let mut hooks = required_codex_hook_metadata(&reported_hooks_path, "untrusted", true); + for hook in &mut hooks { + hook.command = Some(expected_plugin_command()); + } + let mut client = FakeCodexHooksClient { + hook_lists: VecDeque::from([Ok(hooks)]), + ..FakeCodexHooksClient::default() + }; + + let error = auto_trust_codex_hooks( + &mut client, + dir.path(), + &config_path, + &expected_plugin_command(), + ) + .unwrap_err(); + + assert!(error.contains("loaded modified Relay hooks"), "{error}"); + assert!(client.trusted.is_empty()); +} + +#[test] +fn codex_hook_trust_report_distinguishes_modified_disabled_and_missing_hooks() { + let dir = tempdir().unwrap(); + let hooks_path = dir.path().join(".codex").join("hooks.json"); + let hooks = vec![ + codex_hook_metadata( + &hooks_path, + "session_start", + "trusted-hook", + "trusted", + true, + ), + codex_hook_metadata( + &hooks_path, + "user_prompt_submit", + "modified-hook", + "modified", + false, + ), + ]; + let mut client = FakeCodexHooksClient { + hook_lists: VecDeque::from([Ok(hooks)]), + ..FakeCodexHooksClient::default() + }; + + let report = + codex_hook_trust_report_with_client(&mut client, dir.path(), &expected_plugin_command()) + .unwrap(); + let json = report.to_json(); + + assert!(!report.ready()); + assert_eq!(json["trusted"], json!(["trusted-hook"])); + assert_eq!(json["modified"], json!(["modified-hook"])); + assert_eq!(json["disabled"], json!(["modified-hook"])); + assert_eq!( + json["missing_required"], + json!([ + "PreToolUse", + "PostToolUse", + "PermissionRequest", + "SubagentStart", + "SubagentStop", + "Stop", + "PreCompact", + "PostCompact" + ]) + ); +} + +#[test] +fn codex_hook_state_key_path_quotes_arbitrary_hook_identity() { + assert_eq!( + hook_state_key_path("path:hook.\"quoted\""), + r#"hooks.state."path:hook.\"quoted\"""# + ); +} + +#[cfg(not(windows))] +#[test] +fn codex_app_server_client_handshakes_lists_trusts_and_clears_hooks() { + let dir = tempdir().unwrap(); + let _home = HomeScope::enter(dir.path()); + let hooks_path = dir.path().join(".codex").join("hooks.json"); + let hooks = required_codex_hook_metadata(&hooks_path, "trusted", true); + let (_path, _hooks, _log) = fake_codex_app_server(dir.path(), &hooks); + let mut client = CodexAppServerClient::start().unwrap(); + + let listed = client.list_hooks(dir.path()).unwrap(); + client.trust_hooks(&listed).unwrap(); + client + .clear_hook_trust(&["relay-hook-0".to_string()]) + .unwrap(); + client + .restore_hook_trust(&[ + ( + "relay-hook-0".to_string(), + Some(json!({"trusted_hash": "sha256:old", "enabled": false})), + ), + ("relay-hook-1".to_string(), None), + ]) + .unwrap(); + drop(client); + + let requests = fs::read_to_string(dir.path().join("fake-codex-requests.jsonl")).unwrap(); + assert!(requests.contains(r#""method":"initialize""#)); + assert!(requests.contains(r#""method":"hooks/list""#)); + assert!(requests.contains(r#""method":"config/batchWrite""#)); + assert!(requests.contains(r#""trusted_hash":"sha256:relay-hook-0""#)); + assert!(requests.contains(r#""keyPath":"hooks.state.\"relay-hook-0\"""#)); + assert!(requests.contains(r#""trusted_hash":"sha256:old""#)); + assert!(requests.contains(r#""keyPath":"hooks.state.\"relay-hook-1\"""#)); + assert!(requests.contains(r#""value":null"#)); +} + +#[cfg(not(windows))] +#[test] +fn codex_setup_snapshot_restores_exact_files_and_trust() { + let dir = tempdir().unwrap(); + let _home = HomeScope::enter(dir.path()); + let codex_dir = dir.path().join(".codex"); + fs::create_dir_all(&codex_dir).unwrap(); + let config_path = codex_dir.join("config.toml"); + let hooks_path = codex_dir.join("hooks.json"); + let config_backup = backup_path(&config_path); + let hooks_backup = backup_path(&hooks_path); + fs::write( + &config_path, + "[hooks.state.\"relay-hook-0\"]\ntrusted_hash = \"sha256:relay-hook-0\"\nenabled = true\n", + ) + .unwrap(); + fs::write(&hooks_path, "{\"custom\":true}\n").unwrap(); + fs::write(&config_backup, "original config backup\n").unwrap(); + fs::write(&hooks_backup, "original hooks backup\n").unwrap(); + let original = [ + fs::read(&config_path).unwrap(), + fs::read(&config_backup).unwrap(), + fs::read(&hooks_path).unwrap(), + fs::read(&hooks_backup).unwrap(), + ]; + let metadata = required_codex_hook_metadata(&hooks_path, "trusted", true); + let (_path, _hooks, _log) = fake_codex_app_server(dir.path(), &metadata); + let snapshot = snapshot_codex_setup().unwrap(); + + fs::write(&config_path, "model = \"changed\"\n").unwrap(); + fs::write(&hooks_path, "{}\n").unwrap(); + fs::remove_file(&config_backup).unwrap(); + fs::remove_file(&hooks_backup).unwrap(); + restore_codex_setup(&snapshot).unwrap(); + + assert_eq!(fs::read(&config_path).unwrap(), original[0]); + assert_eq!(fs::read(&config_backup).unwrap(), original[1]); + assert_eq!(fs::read(&hooks_path).unwrap(), original[2]); + assert_eq!(fs::read(&hooks_backup).unwrap(), original[3]); +} + +#[test] +fn codex_install_rolls_back_all_files_when_trust_activation_fails() { + let dir = tempdir().unwrap(); + let _home = HomeScope::enter(dir.path()); + let codex_dir = dir.path().join(".codex"); + fs::create_dir_all(&codex_dir).unwrap(); + let config_path = codex_dir.join("config.toml"); + let hooks_path = codex_dir.join("hooks.json"); + let config_backup = backup_path(&config_path); + let hooks_backup = backup_path(&hooks_path); + fs::write(&config_path, "model_provider = \"openai\"\n").unwrap(); + fs::write(&hooks_path, "{}\n").unwrap(); + fs::write(&config_backup, "original config backup\n").unwrap(); + fs::write(&hooks_backup, "original hooks backup\n").unwrap(); + + let error = install_codex_with_trust( + DEFAULT_URL, + &expected_plugin_command(), + |_home, _config, _command| Err("Codex trust write rejected".into()), + ) + .unwrap_err(); + + assert!(error.contains("trust write rejected")); + assert_eq!( + fs::read_to_string(&config_path).unwrap(), + "model_provider = \"openai\"\n" + ); + assert_eq!(fs::read_to_string(&hooks_path).unwrap(), "{}\n"); + assert_eq!( + fs::read_to_string(&config_backup).unwrap(), + "original config backup\n" + ); + assert_eq!( + fs::read_to_string(&hooks_backup).unwrap(), + "original hooks backup\n" + ); +} + +#[test] +fn repeated_codex_install_does_not_overwrite_original_backup() { + let dir = tempdir().unwrap(); + let _home = HomeScope::enter(dir.path()); + let path = dir.path().join(".codex").join("config.toml"); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(&path, "model_provider = \"openai\"\n").unwrap(); + + install_codex_config(&path, DEFAULT_URL).unwrap(); + install_codex_config(&path, DEFAULT_URL).unwrap(); + + assert_eq!( + fs::read_to_string(backup_path(&path)).unwrap(), + "model_provider = \"openai\"\n" + ); + let doc = fs::read_to_string(&path) + .unwrap() + .parse::() + .unwrap(); + let token = codex_provider_client_token(&doc).unwrap(); + assert!( + BootstrapChallengeKey::load() + .unwrap() + .verify_client_token(token) + ); +} + +#[test] +fn codex_client_token_supports_regular_header_tables() { + let document = r#" +[model_providers.nemo-relay-openai] +name = "NeMo Relay" + +[model_providers.nemo-relay-openai.http_headers] +X-NeMo-Relay-Client-Token = "regular-table-token" +"# + .parse::() + .unwrap(); + + assert_eq!( + codex_provider_client_token(&document), + Some("regular-table-token") + ); +} + +#[cfg(unix)] +#[test] +fn codex_install_tightens_the_secret_bearing_config_to_owner_only() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempdir().unwrap(); + let _home = HomeScope::enter(dir.path()); + let path = dir.path().join(".codex").join("config.toml"); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(&path, "model_provider = \"openai\"\n").unwrap(); + fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap(); + + install_codex_config(&path, DEFAULT_URL).unwrap(); + + assert_eq!( + fs::metadata(path).unwrap().permissions().mode() & 0o777, + 0o600 + ); +} + +#[test] +fn codex_reinstall_refreshes_a_stale_backup_before_overwriting_user_changes() { + let dir = tempdir().unwrap(); + let _home = HomeScope::enter(dir.path()); + let path = dir.path().join(".codex").join("config.toml"); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(&path, "model_provider = \"openai\"\n").unwrap(); + install_codex_config(&path, DEFAULT_URL).unwrap(); + + let user_owned = "model_provider = \"local\"\ncustom = \"preserve-me\"\n"; + fs::write(&path, user_owned).unwrap(); + install_codex_config(&path, DEFAULT_URL).unwrap(); + assert_eq!(fs::read_to_string(backup_path(&path)).unwrap(), user_owned); + + uninstall_codex_config(&path, DEFAULT_URL, false).unwrap(); + + assert_eq!(fs::read_to_string(&path).unwrap(), user_owned); + assert!(!backup_path(&path).exists()); +} + +#[test] +fn codex_reinstall_sanitizes_managed_fields_from_a_partial_edit_backup() { + let dir = tempdir().unwrap(); + let _home = HomeScope::enter(dir.path()); + let path = dir.path().join(".codex").join("config.toml"); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(&path, "model_provider = \"openai\"\n").unwrap(); + install_codex_config(&path, DEFAULT_URL).unwrap(); + + let installed = fs::read_to_string(&path).unwrap(); + let partially_edited = installed.replacen( + "model_provider = \"nemo-relay-openai\"", + "model_provider = \"local\"", + 1, + ); + assert_ne!(installed, partially_edited); + fs::write(&path, partially_edited).unwrap(); + + install_codex_config(&path, DEFAULT_URL).unwrap(); + let backup = fs::read_to_string(backup_path(&path)).unwrap(); + assert!(backup.contains("model_provider = \"local\"")); + assert!(!backup.contains("nemo-relay-openai")); + assert!(!backup.contains(BOOTSTRAP_CLIENT_TOKEN_HEADER)); + + uninstall_codex_config(&path, DEFAULT_URL, false).unwrap(); + let uninstalled = fs::read_to_string(&path).unwrap(); + assert!(uninstalled.contains("model_provider = \"local\"")); + assert!(!uninstalled.contains("nemo-relay-openai")); + assert!(!uninstalled.contains(BOOTSTRAP_CLIENT_TOKEN_HEADER)); + assert!(!backup_path(&path).exists()); +} + +#[test] +fn codex_uninstall_migrates_a_contaminated_legacy_backup() { + let dir = tempdir().unwrap(); + let _home = HomeScope::enter(dir.path()); + let path = dir.path().join(".codex").join("config.toml"); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write( + &path, + "model_provider = \"openai\"\ncustom = \"preserve-me\"\n", + ) + .unwrap(); + install_codex_config(&path, DEFAULT_URL).unwrap(); + + // Older partial-reinstall logic could replace the original backup with the complete + // generated config. Uninstall must recognize its authenticated proof and not restore it. + let contaminated = fs::read(&path).unwrap(); + fs::write(backup_path(&path), contaminated).unwrap(); + + uninstall_codex_config(&path, DEFAULT_URL, false).unwrap(); + let uninstalled = fs::read_to_string(&path).unwrap(); + assert!(uninstalled.contains("custom = \"preserve-me\"")); + assert!(!uninstalled.contains("nemo-relay-openai")); + assert!(!uninstalled.contains(BOOTSTRAP_CLIENT_TOKEN_HEADER)); + assert!(!uninstalled.contains("hooks = true")); + assert!(!backup_path(&path).exists()); +} + +#[test] +fn codex_backup_migration_preserves_user_provider_extensions() { + let dir = tempdir().unwrap(); + let _home = HomeScope::enter(dir.path()); + let path = dir.path().join(".codex").join("config.toml"); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(&path, "model_provider = \"openai\"\n").unwrap(); + install_codex_config(&path, DEFAULT_URL).unwrap(); + + let extended = fs::read_to_string(&path).unwrap().replace( + "name = \"NeMo Relay\"", + "name = \"NeMo Relay\"\nuser_option = \"keep\"", + ); + fs::write(&path, &extended).unwrap(); + fs::write(backup_path(&path), &extended).unwrap(); + + uninstall_codex_config(&path, DEFAULT_URL, false).unwrap(); + let uninstalled = fs::read_to_string(&path).unwrap(); + assert!(uninstalled.contains("user_option = \"keep\"")); + assert!(!uninstalled.contains("model_provider = \"nemo-relay-openai\"")); + assert!(uninstalled.contains(&format!("base_url = \"{DEFAULT_URL}\""))); + assert!(!uninstalled.contains(BOOTSTRAP_CLIENT_TOKEN_HEADER)); + assert!(!uninstalled.contains("hooks = true")); + assert!(!backup_path(&path).exists()); +} + +#[test] +fn codex_reinstall_round_trips_user_provider_fields_and_headers() { + let dir = tempdir().unwrap(); + let _home = HomeScope::enter(dir.path()); + let path = dir.path().join(".codex").join("config.toml"); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(&path, "model_provider = \"openai\"\n").unwrap(); + install_codex_config(&path, DEFAULT_URL).unwrap(); + + let mut extended = fs::read_to_string(&path) + .unwrap() + .parse::() + .unwrap(); + let provider = extended["model_providers"]["nemo-relay-openai"] + .as_table_mut() + .unwrap(); + provider["user_option"] = Item::Value(TomlValue::from("keep")); + provider["http_headers"] + .as_inline_table_mut() + .unwrap() + .insert("x-user-header", TomlValue::from("keep-header")); + fs::write(&path, extended.to_string()).unwrap(); + + install_codex_config(&path, DEFAULT_URL).unwrap(); + let reinstalled = fs::read_to_string(&path).unwrap(); + assert!(reinstalled.contains("user_option = \"keep\"")); + assert!(reinstalled.contains("x-user-header = \"keep-header\"")); + assert!(reinstalled.contains(BOOTSTRAP_CLIENT_TOKEN_HEADER)); + let backup = fs::read_to_string(backup_path(&path)).unwrap(); + assert!(backup.contains("model_provider = \"openai\"")); + assert!(backup.contains("user_option = \"keep\"")); + assert!(backup.contains("x-user-header = \"keep-header\"")); + assert!(!backup.contains(BOOTSTRAP_CLIENT_TOKEN_HEADER)); + assert!(backup.contains(&format!("base_url = \"{DEFAULT_URL}\""))); + + uninstall_codex_config(&path, DEFAULT_URL, false).unwrap(); + let uninstalled = fs::read_to_string(&path).unwrap(); + assert!(uninstalled.contains("model_provider = \"openai\"")); + assert!(uninstalled.contains("user_option = \"keep\"")); + assert!(uninstalled.contains("x-user-header = \"keep-header\"")); + assert!(!uninstalled.contains(BOOTSTRAP_CLIENT_TOKEN_HEADER)); + assert!(uninstalled.contains(&format!("base_url = \"{DEFAULT_URL}\""))); + assert!(!uninstalled.contains("hooks = true")); +} + +#[test] +fn codex_direct_uninstall_preserves_a_complete_extended_provider_inactively() { + let dir = tempdir().unwrap(); + let _home = HomeScope::enter(dir.path()); + let path = dir.path().join(".codex").join("config.toml"); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(&path, "model_provider = \"openai\"\n").unwrap(); + install_codex_config(&path, DEFAULT_URL).unwrap(); + let mut extended = fs::read_to_string(&path) + .unwrap() + .parse::() + .unwrap(); + let provider = extended["model_providers"]["nemo-relay-openai"] + .as_table_mut() + .unwrap(); + provider["user_option"] = Item::Value(TomlValue::from("keep")); + provider["http_headers"] + .as_inline_table_mut() + .unwrap() + .insert("x-user-header", TomlValue::from("keep-header")); + fs::write(&path, extended.to_string()).unwrap(); + + uninstall_codex_config(&path, DEFAULT_URL, false).unwrap(); + let uninstalled = fs::read_to_string(&path).unwrap(); + assert!(uninstalled.contains("model_provider = \"openai\"")); + assert!(uninstalled.contains(&format!("base_url = \"{DEFAULT_URL}\""))); + assert!(uninstalled.contains("name = \"NeMo Relay\"")); + assert!(uninstalled.contains("user_option = \"keep\"")); + assert!(uninstalled.contains("x-user-header = \"keep-header\"")); + assert!(!uninstalled.contains(BOOTSTRAP_CLIENT_TOKEN_HEADER)); + assert!(!uninstalled.contains("hooks = true")); +} + +#[test] +fn codex_uninstall_sanitizes_an_extended_contaminated_backup_without_the_key() { + let dir = tempdir().unwrap(); + let _home = HomeScope::enter(dir.path()); + let path = dir.path().join(".codex").join("config.toml"); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(&path, "model_provider = \"openai\"\ncustom = \"keep\"\n").unwrap(); + install_codex_config(&path, DEFAULT_URL).unwrap(); + let mut installed = fs::read_to_string(&path) + .unwrap() + .parse::() + .unwrap(); + let provider = installed["model_providers"]["nemo-relay-openai"] + .as_table_mut() + .unwrap(); + provider["user_option"] = Item::Value(TomlValue::from("keep")); + provider["http_headers"] + .as_inline_table_mut() + .unwrap() + .insert("x-user-header", TomlValue::from("keep-header")); + fs::write(&path, installed.to_string()).unwrap(); + fs::write(backup_path(&path), fs::read(&path).unwrap()).unwrap(); + let key_path = crate::configuration::user_config_dir() + .unwrap() + .join("bootstrap/fingerprint-hmac.key"); + fs::remove_file(key_path).unwrap(); + + uninstall_codex_config(&path, DEFAULT_URL, false).unwrap(); + let uninstalled = fs::read_to_string(&path).unwrap(); + assert!(uninstalled.contains("custom = \"keep\"")); + assert!(!uninstalled.contains("model_provider = \"nemo-relay-openai\"")); + assert!(uninstalled.contains(&format!("base_url = \"{DEFAULT_URL}\""))); + assert!(uninstalled.contains("user_option = \"keep\"")); + assert!(uninstalled.contains("x-user-header = \"keep-header\"")); + assert!(!uninstalled.contains(BOOTSTRAP_CLIENT_TOKEN_HEADER)); + assert!(!uninstalled.contains("hooks = true")); +} + +#[test] +fn codex_uninstall_sanitizes_an_extended_contaminated_backup_after_key_rotation() { + let dir = tempdir().unwrap(); + let _home = HomeScope::enter(dir.path()); + let path = dir.path().join(".codex").join("config.toml"); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(&path, "model_provider = \"openai\"\ncustom = \"keep\"\n").unwrap(); + install_codex_config(&path, DEFAULT_URL).unwrap(); + let mut installed = fs::read_to_string(&path) + .unwrap() + .parse::() + .unwrap(); + let provider = installed["model_providers"]["nemo-relay-openai"] + .as_table_mut() + .unwrap(); + provider["user_option"] = Item::Value(TomlValue::from("keep")); + provider["http_headers"] + .as_inline_table_mut() + .unwrap() + .insert("x-user-header", TomlValue::from("keep-header")); + fs::write(&path, installed.to_string()).unwrap(); + fs::write(backup_path(&path), fs::read(&path).unwrap()).unwrap(); + let key_path = crate::configuration::user_config_dir() + .unwrap() + .join("bootstrap/fingerprint-hmac.key"); + fs::write(key_path, [0x5a; 32]).unwrap(); + + uninstall_codex_config(&path, DEFAULT_URL, false).unwrap(); + let uninstalled = fs::read_to_string(&path).unwrap(); + assert!(uninstalled.contains("custom = \"keep\"")); + assert!(!uninstalled.contains("model_provider = \"nemo-relay-openai\"")); + assert!(uninstalled.contains(&format!("base_url = \"{DEFAULT_URL}\""))); + assert!(uninstalled.contains("user_option = \"keep\"")); + assert!(uninstalled.contains("x-user-header = \"keep-header\"")); + assert!(!uninstalled.contains(BOOTSTRAP_CLIENT_TOKEN_HEADER)); + assert!(!uninstalled.contains("hooks = true")); +} + +#[test] +fn codex_reinstall_repairs_a_rotated_client_proof_and_keeps_custom_headers() { + let dir = tempdir().unwrap(); + let _home = HomeScope::enter(dir.path()); + let path = dir.path().join(".codex").join("config.toml"); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(&path, "model_provider = \"openai\"\n").unwrap(); + install_codex_config(&path, DEFAULT_URL).unwrap(); + let mut installed = fs::read_to_string(&path) + .unwrap() + .parse::() + .unwrap(); + installed["model_providers"]["nemo-relay-openai"]["http_headers"] + .as_inline_table_mut() + .unwrap() + .insert("x-user-header", TomlValue::from("keep-header")); + fs::write(&path, installed.to_string()).unwrap(); + let key_path = crate::configuration::user_config_dir() + .unwrap() + .join("bootstrap/fingerprint-hmac.key"); + fs::write(key_path, [0x3c; 32]).unwrap(); + + install_codex_config(&path, DEFAULT_URL).unwrap(); + let reinstalled = fs::read_to_string(&path) + .unwrap() + .parse::() + .unwrap(); + let token = codex_provider_client_token(&reinstalled).unwrap(); + assert!( + BootstrapChallengeKey::load_existing() + .unwrap() + .unwrap() + .verify_client_token(token) + ); + assert_eq!( + codex_provider_header(&reinstalled, "x-user-header").and_then(TomlValue::as_str), + Some("keep-header") + ); + let backup = fs::read_to_string(backup_path(&path)).unwrap(); + assert!(!backup.contains(BOOTSTRAP_CLIENT_TOKEN_HEADER)); + assert!(backup.contains("x-user-header = \"keep-header\"")); +} + +#[cfg(unix)] +#[test] +fn codex_install_rollback_restores_original_private_permissions() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempdir().unwrap(); + let _home = HomeScope::enter(dir.path()); + let codex_dir = dir.path().join(".codex"); + let path = codex_dir.join("config.toml"); + fs::create_dir_all(&codex_dir).unwrap(); + fs::write(&path, "model_provider = \"openai\"\n").unwrap(); + fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).unwrap(); + + let error = install_codex_with_trust( + DEFAULT_URL, + &expected_plugin_command(), + |_home, _config, _command| Err("injected trust failure".into()), + ) + .unwrap_err(); + + assert!(error.contains("injected trust failure"), "{error}"); + assert_eq!( + fs::read_to_string(&path).unwrap(), + "model_provider = \"openai\"\n" + ); + assert_eq!( + fs::metadata(path).unwrap().permissions().mode() & 0o777, + 0o600 + ); +} + +#[cfg(windows)] +#[test] +fn codex_install_rollback_restores_the_original_windows_dacl() { + let dir = tempdir().unwrap(); + let _home = HomeScope::enter(dir.path()); + let codex_dir = dir.path().join(".codex"); + let path = codex_dir.join("config.toml"); + fs::create_dir_all(&codex_dir).unwrap(); + fs::write(&path, "model_provider = \"openai\"\n").unwrap(); + set_windows_dacl(&path, "D:P(A;;FA;;;SY)(A;;GRGW;;;WD)"); + let original_dacl = crate::filesystem::read_windows_dacl(&path).unwrap(); + + let error = install_codex_with_trust( + DEFAULT_URL, + &expected_plugin_command(), + |_home, _config, _command| Err("injected trust failure".into()), + ) + .unwrap_err(); + + assert!(error.contains("injected trust failure"), "{error}"); + assert_eq!( + fs::read_to_string(&path).unwrap(), + "model_provider = \"openai\"\n" + ); + assert_eq!( + crate::filesystem::read_windows_dacl(&path).unwrap(), + original_dacl + ); +} + +#[cfg(windows)] +fn set_windows_dacl(path: &Path, sddl: &str) { + use std::os::windows::ffi::OsStrExt; + use windows_sys::Win32::Foundation::LocalFree; + use windows_sys::Win32::Security::Authorization::{ + ConvertStringSecurityDescriptorToSecurityDescriptorW, SDDL_REVISION_1, + }; + use windows_sys::Win32::Security::{ + DACL_SECURITY_INFORMATION, PROTECTED_DACL_SECURITY_INFORMATION, PSECURITY_DESCRIPTOR, + SetFileSecurityW, + }; + + let sddl = std::ffi::OsStr::new(sddl) + .encode_wide() + .chain(Some(0)) + .collect::>(); + let mut descriptor: PSECURITY_DESCRIPTOR = std::ptr::null_mut(); + // SAFETY: The SDDL is NUL-terminated and the output pointer is valid. + assert_ne!( + unsafe { + ConvertStringSecurityDescriptorToSecurityDescriptorW( + sddl.as_ptr(), + SDDL_REVISION_1, + &mut descriptor, + std::ptr::null_mut(), + ) + }, + 0, + "{}", + std::io::Error::last_os_error() + ); + let path = path + .as_os_str() + .encode_wide() + .chain(Some(0)) + .collect::>(); + // SAFETY: The path and descriptor are valid for the duration of the call. + let result = unsafe { + SetFileSecurityW( + path.as_ptr(), + DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION, + descriptor, + ) + }; + // SAFETY: The descriptor was allocated by ConvertStringSecurityDescriptor... above. + unsafe { LocalFree(descriptor.cast()) }; + assert_ne!(result, 0, "{}", std::io::Error::last_os_error()); +} + +#[test] +fn codex_upgrade_adds_client_proof_without_replacing_original_backup() { + let dir = tempdir().unwrap(); + let _home = HomeScope::enter(dir.path()); + let path = dir.path().join(".codex").join("config.toml"); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(&path, "model_provider = \"openai\"\n").unwrap(); + install_codex_config(&path, DEFAULT_URL).unwrap(); + let original_backup = fs::read(backup_path(&path)).unwrap(); + + let mut legacy = fs::read_to_string(&path) + .unwrap() + .parse::() + .unwrap(); + legacy["model_providers"]["nemo-relay-openai"] + .as_table_mut() + .unwrap() + .remove("http_headers"); + fs::write(&path, legacy.to_string()).unwrap(); + + install_codex_config(&path, DEFAULT_URL).unwrap(); + + assert_eq!(fs::read(backup_path(&path)).unwrap(), original_backup); + assert!(codex_provider_installed(DEFAULT_URL)); +} + +#[test] +fn codex_install_backs_up_when_relay_provider_table_is_not_active() { + let dir = tempdir().unwrap(); + let path = dir.path().join("config.toml"); + fs::write( + &path, + r#" +model_provider = "openai" + +[model_providers.nemo-relay-openai] +name = "NeMo Relay" +base_url = "http://127.0.0.1:47632" +wire_api = "responses" +requires_openai_auth = true +supports_websockets = false +"#, + ) + .unwrap(); + + install_codex_config(&path, DEFAULT_URL).unwrap(); + + assert!( + fs::read_to_string(backup_path(&path)) + .unwrap() + .contains("model_provider = \"openai\"") + ); +} + +#[test] +fn codex_install_backs_up_when_hooks_flag_changes_even_with_managed_provider() { + let dir = tempdir().unwrap(); + let path = dir.path().join("config.toml"); + fs::write( + &path, + r#" +model_provider = "nemo-relay-openai" + +[features] +hooks = false + +[model_providers.nemo-relay-openai] +name = "NeMo Relay" +base_url = "http://127.0.0.1:47632" +wire_api = "responses" +requires_openai_auth = true +supports_websockets = false +"#, + ) + .unwrap(); + + install_codex_config(&path, DEFAULT_URL).unwrap(); + + let backup = fs::read_to_string(backup_path(&path)).unwrap(); + assert!(backup.contains("hooks = false")); + uninstall_codex_config(&path, DEFAULT_URL, false).unwrap(); + let updated = fs::read_to_string(&path).unwrap(); + assert!(updated.contains("hooks = false")); +} + +#[test] +fn codex_provider_installed_requires_active_managed_provider() { + let dir = tempdir().unwrap(); + let _home = HomeScope::enter(dir.path()); + let xdg = dir.path().join("xdg"); + let _xdg = EnvVarGuard::set_path("XDG_CONFIG_HOME", &xdg); + let codex_dir = dir.path().join(".codex"); + fs::create_dir_all(&codex_dir).unwrap(); + let path = codex_dir.join("config.toml"); + fs::write( + &path, + r#" +model_provider = "openai" + +[model_providers.nemo-relay-openai] +name = "NeMo Relay" +base_url = "http://127.0.0.1:47632" +wire_api = "responses" +requires_openai_auth = true +supports_websockets = false +"#, + ) + .unwrap(); + + assert!(!codex_provider_installed(DEFAULT_URL)); + assert!( + !xdg.join("nemo-relay/bootstrap/fingerprint-hmac.key") + .exists(), + "read-only provider diagnosis must not create bootstrap state" + ); + install_codex_config(&path, DEFAULT_URL).unwrap(); + assert!(codex_provider_installed(DEFAULT_URL)); + let mut tampered = fs::read_to_string(&path) + .unwrap() + .parse::() + .unwrap(); + tampered["model_providers"]["nemo-relay-openai"]["http_headers"] + .as_inline_table_mut() + .unwrap() + .insert( + BOOTSTRAP_CLIENT_TOKEN_HEADER, + TomlValue::from("hmac-sha256:wrong"), + ); + fs::write(&path, tampered.to_string()).unwrap(); + assert!(!codex_provider_installed(DEFAULT_URL)); + install_codex_config(&path, DEFAULT_URL).unwrap(); + assert!(codex_provider_installed(DEFAULT_URL)); + assert!(!codex_provider_installed("http://127.0.0.1:47633")); + fs::write( + &path, + r#" +model_provider = "nemo-relay-openai" + +[features] +hooks = false + +[model_providers.nemo-relay-openai] +name = "NeMo Relay" +base_url = "http://127.0.0.1:47632" +wire_api = "responses" +requires_openai_auth = true +supports_websockets = false +"#, + ) + .unwrap(); + assert!(!codex_provider_installed(DEFAULT_URL)); +} + +#[test] +fn codex_hooks_installed_requires_generated_plugin_local_groups() { + let dir = tempdir().unwrap(); + let plugin_root = dir.path().join("plugin"); + let path = plugin_root.join("hooks").join("hooks.json"); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + write_plugin_generation_for_hooks(&path); + fs::write( + &path, + serde_json::to_vec_pretty(&json!({ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "nemo-relay plugin-shim hook codex --gateway-url http://127.0.0.1:47632", + "timeout": 30 + } + ] + } + ] + } + })) + .unwrap(), + ) + .unwrap(); + + assert!(!codex_hooks_installed(&path).unwrap()); + write_plugin_hooks(&plugin_root); + assert!(codex_hooks_installed(&path).unwrap()); +} + +#[test] +fn codex_setup_can_validate_hooks_while_installer_holds_the_generation_lock() { + let dir = tempdir().unwrap(); + let plugin_root = dir.path().join("plugin"); + let hooks_path = plugin_root.join("hooks").join("hooks.json"); + let generation_path = plugin_root.join(crate::installation::generation::GENERATION_FILE_NAME); + let generation_lock = dir.path().join("generation-transaction.lock"); + let token = crate::installation::generation::write_new_generation_with_token_at( + &generation_path, + &generation_lock, + ) + .unwrap(); + let relay = current_exe().unwrap(); + let relay = portable_executable_path(relay.canonicalize().unwrap_or(relay)); + let command = codex_plugin_hook_command(&relay, &generation_path, &token).unwrap(); + fs::create_dir_all(hooks_path.parent().unwrap()).unwrap(); + fs::write( + &hooks_path, + serde_json::to_vec_pretty(&generated_hooks(CodingAgent::Codex, &command)).unwrap(), + ) + .unwrap(); + let _transaction = + crate::installation::generation::GenerationRetirement::acquire(&generation_path) + .unwrap() + .unwrap(); + + assert!( + codex_hooks_installed_with_generation(&hooks_path, Some(&token)).unwrap(), + "installer-owned validation must use its verified token instead of reacquiring its lock" + ); +} + +#[cfg(not(windows))] +#[test] +fn codex_doctor_requires_app_server_reported_trust_but_allows_stopped_sidecar() { + let dir = tempdir().unwrap(); + let _home = HomeScope::enter(dir.path()); + let codex_dir = dir.path().join(".codex"); + fs::create_dir_all(&codex_dir).unwrap(); + install_codex_config(&codex_dir.join("config.toml"), DEFAULT_URL).unwrap(); + let plugin_root = dir.path().join("plugin"); + let hooks_path = write_plugin_hooks(&plugin_root); + let trusted = required_codex_hook_metadata(&hooks_path, "trusted", true); + let (_path, _hooks, _log) = fake_codex_app_server(dir.path(), &trusted); + let _plugin_root = EnvVarGuard::set_path("PLUGIN_ROOT", &plugin_root); + + doctor_plugin(CodingAgent::Codex, DEFAULT_URL, &plugin_root).unwrap(); + let report = doctor_plugin_json(CodingAgent::Codex, DEFAULT_URL, &plugin_root).unwrap(); + assert_eq!(report["checks"]["codex_hooks_trusted"], json!(true)); + assert_eq!( + report["codex_hook_trust"]["trusted"], + json!( + (0..10) + .map(|index| format!("relay-hook-{index}")) + .collect::>() + ) + ); +} + +#[test] +fn codex_provider_install_check_requires_enabled_hooks_feature() { + let dir = tempdir().unwrap(); + let _home = HomeScope::enter(dir.path()); + let codex_dir = dir.path().join(".codex"); + fs::create_dir_all(&codex_dir).unwrap(); + fs::write( + codex_dir.join("config.toml"), + r#" +model_provider = "nemo-relay-openai" + +[features] +hooks = false + +[model_providers.nemo-relay-openai] +name = "NeMo Relay" +base_url = "http://127.0.0.1:47632" +wire_api = "responses" +requires_openai_auth = true +supports_websockets = false +"#, + ) + .unwrap(); + let plugin_root = dir.path().join("plugin"); + let hooks_path = write_plugin_hooks(&plugin_root); + + assert!(!codex_provider_installed(DEFAULT_URL)); + assert!(codex_hooks_installed(&hooks_path).unwrap()); +} + +#[test] +fn plugin_host_doctor_rejects_unsupported_agents_and_reports_lazy_claude_status() { + let dir = tempdir().unwrap(); + let _home = HomeScope::enter(dir.path()); + + assert!( + doctor_plugin(CodingAgent::Hermes, DEFAULT_URL, dir.path()) + .unwrap_err() + .contains("supports claude and codex") + ); + assert!( + doctor_plugin_json(CodingAgent::Hermes, DEFAULT_URL, dir.path()) + .unwrap_err() + .contains("supports claude and codex") + ); + + let report = doctor_plugin_json(CodingAgent::ClaudeCode, DEFAULT_URL, dir.path()).unwrap(); + assert_eq!(report["ok"], json!(false)); + assert_eq!(report["sidecar_health"], json!("not_running_mcp_start")); + assert_eq!(report["checks"]["claude_provider_routing"], json!(false)); +} + +#[test] +fn codex_setup_uses_plugin_hooks_without_writing_user_hooks() { + let dir = tempdir().unwrap(); + let _home = HomeScope::enter(dir.path()); + let codex_dir = dir.path().join(".codex"); + fs::create_dir_all(&codex_dir).unwrap(); + + install_codex_with_trust( + DEFAULT_URL, + &expected_plugin_command(), + |_home, _config, command| { + assert_eq!(command, expected_plugin_command()); + Ok(()) + }, + ) + .unwrap(); + + let hooks_path = codex_dir.join("hooks.json"); + assert!(!hooks_path.exists()); + assert!(codex_provider_installed(DEFAULT_URL)); + + let mut client = empty_codex_hooks_client(); + uninstall_codex_with_client(DEFAULT_URL, Some(&mut client)).unwrap(); + assert!(!hooks_path.exists()); +} + +#[test] +fn codex_setup_and_uninstall_honor_custom_codex_home() { + let dir = tempdir().unwrap(); + let _home = HomeScope::enter(&dir.path().join("home")); + let codex_home = dir.path().join("custom-codex-home"); + let _codex_home = EnvVarGuard::set_path("CODEX_HOME", &codex_home); + + install_codex_with_trust( + DEFAULT_URL, + &expected_plugin_command(), + |_cwd, config_path, _command| { + assert_eq!(config_path, codex_home.join("config.toml")); + Ok(()) + }, + ) + .unwrap(); + + assert!(codex_provider_installed(DEFAULT_URL)); + assert!(codex_home.join("config.toml").exists()); + assert!(!dir.path().join("home/.codex/config.toml").exists()); + + let mut client = empty_codex_hooks_client(); + uninstall_codex_with_client(DEFAULT_URL, Some(&mut client)).unwrap(); + assert!(!codex_provider_installed(DEFAULT_URL)); +} + +#[test] +fn relay_binary_prefers_sidecar_binary_override() { + let dir = tempdir().unwrap(); + let _home = HomeScope::enter(dir.path()); + let sidecar_override = dir.path().join("sidecar").join("nemo-relay"); + fs::create_dir_all(sidecar_override.parent().unwrap()).unwrap(); + fs::write(&sidecar_override, b"sidecar override").unwrap(); + let _binary_override = EnvVarGuard::set_path("NEMO_RELAY_PLUGIN_BINARY", &sidecar_override); + + assert_eq!(relay_binary().unwrap(), sidecar_override); +} + +#[test] +fn codex_uninstall_without_backup_removes_managed_hooks_flag() { + let dir = tempdir().unwrap(); + let path = dir.path().join("config.toml"); + fs::write( + &path, + r#" +model_provider = "nemo-relay-openai" + +[features] +hooks = true + +[model_providers.nemo-relay-openai] +name = "NeMo Relay" +base_url = "http://127.0.0.1:47632" +wire_api = "responses" +requires_openai_auth = true +supports_websockets = false +"#, + ) + .unwrap(); + + uninstall_codex_config(&path, DEFAULT_URL, false).unwrap(); + let updated = fs::read_to_string(&path).unwrap(); + + assert!(!updated.contains("model_provider")); + assert!(!updated.contains("nemo-relay-openai")); + assert!(!updated.contains("hooks = true")); +} + +#[test] +fn codex_uninstall_clears_all_trust_for_the_exact_relay_plugin() { + let dir = tempdir().unwrap(); + let _home = HomeScope::enter(dir.path()); + let codex_dir = dir.path().join(".codex"); + fs::create_dir_all(&codex_dir).unwrap(); + let config_path = codex_dir.join("config.toml"); + let hooks_path = codex_dir.join("hooks.json"); + fs::write(&config_path, "model_provider = \"openai\"\n").unwrap(); + fs::write(&hooks_path, "{}\n").unwrap(); + install_codex_hooks(&hooks_path, DEFAULT_URL).unwrap(); + install_codex_config(&config_path, DEFAULT_URL).unwrap(); + let mut hooks = generated_codex_hook_metadata(&hooks_path, "trusted", true); + let mut unrelated = codex_hook_metadata( + &hooks_path, + "session_start", + "unrelated-hook", + "trusted", + true, + ); + unrelated.command = Some("custom hook".into()); + hooks.push(unrelated); + let mut cleared = hooks.clone(); + for hook in &mut cleared { + hook.trust_status = "untrusted".into(); + } + let mut client = FakeCodexHooksClient { + hook_lists: VecDeque::from([Ok(hooks), Ok(cleared)]), + ..FakeCodexHooksClient::default() + }; + + uninstall_codex_with_client(DEFAULT_URL, Some(&mut client)).unwrap(); + + assert_eq!( + client.cleared, + vec![ + (0..10) + .map(|index| format!("relay-hook-{index}")) + .chain(["unrelated-hook".into()]) + .collect::>() + ] + ); + assert!( + !serde_json::from_str::(&fs::read_to_string(&hooks_path).unwrap()) + .unwrap() + .to_string() + .contains("plugin-shim hook codex") + ); +} + +#[test] +fn codex_uninstall_clears_persisted_optional_hooks_after_downgrade() { + let dir = tempdir().unwrap(); + let _home = HomeScope::enter(dir.path()); + let codex_dir = dir.path().join(".codex"); + fs::create_dir_all(&codex_dir).unwrap(); + let config_path = codex_dir.join("config.toml"); + let hooks_path = codex_dir.join("hooks.json"); + let all_hooks = persisted_relay_hook_metadata(&hooks_path, "trusted"); + let all_keys = all_hooks + .iter() + .map(|hook| hook.key.clone()) + .collect::>(); + let unrelated_key = "other-plugin@example:hooks/hooks.json:session_start:0:0"; + write_persisted_hook_trust(&config_path, &all_keys, unrelated_key); + let visible = all_hooks + .into_iter() + .filter(|hook| { + !matches!( + hook.event_name.as_str(), + "post_tool_use_failure" | "notification" | "session_end" + ) + }) + .collect::>(); + let mut cleared = visible.clone(); + for hook in &mut cleared { + hook.trust_status = "untrusted".into(); + } + let mut client = FakeCodexHooksClient { + hook_lists: VecDeque::from([Ok(visible), Ok(cleared)]), + clear_config_path: Some(config_path.clone()), + ..FakeCodexHooksClient::default() + }; + + uninstall_codex_with_client(DEFAULT_URL, Some(&mut client)).unwrap(); + + assert_eq!( + client.cleared[0].iter().cloned().collect::>(), + all_keys.into_iter().collect::>() + ); + assert_eq!( + configured_hook_trust_keys(&config_path).unwrap(), + BTreeSet::from([unrelated_key.to_string()]) + ); +} + +#[test] +fn codex_uninstall_clears_persisted_relay_trust_when_discovery_is_empty() { + let dir = tempdir().unwrap(); + let _home = HomeScope::enter(dir.path()); + let codex_dir = dir.path().join(".codex"); + fs::create_dir_all(&codex_dir).unwrap(); + let config_path = codex_dir.join("config.toml"); + let hooks_path = codex_dir.join("hooks.json"); + let relay_keys = persisted_relay_hook_metadata(&hooks_path, "trusted") + .into_iter() + .map(|hook| hook.key) + .collect::>(); + let unrelated_key = "other-plugin@example:hooks/hooks.json:session_start:0:0"; + write_persisted_hook_trust(&config_path, &relay_keys, unrelated_key); + let mut client = FakeCodexHooksClient { + hook_lists: VecDeque::from([Ok(Vec::new())]), + clear_config_path: Some(config_path.clone()), + ..FakeCodexHooksClient::default() + }; + + uninstall_codex_with_client(DEFAULT_URL, Some(&mut client)).unwrap(); + + assert_eq!(client.cleared.len(), 1); + assert_eq!( + client.cleared[0].iter().cloned().collect::>(), + relay_keys.into_iter().collect::>() + ); + assert_eq!( + configured_hook_trust_keys(&config_path).unwrap(), + BTreeSet::from([unrelated_key.to_string()]) + ); +} + +#[test] +fn codex_uninstall_rolls_back_persisted_relay_trust_when_clear_is_not_applied() { + let dir = tempdir().unwrap(); + let _home = HomeScope::enter(dir.path()); + let codex_dir = dir.path().join(".codex"); + fs::create_dir_all(&codex_dir).unwrap(); + let config_path = codex_dir.join("config.toml"); + let hooks_path = codex_dir.join("hooks.json"); + let relay_keys = persisted_relay_hook_metadata(&hooks_path, "trusted") + .into_iter() + .map(|hook| hook.key) + .collect::>(); + let unrelated_key = "other-plugin@example:hooks/hooks.json:session_start:0:0"; + write_persisted_hook_trust(&config_path, &relay_keys, unrelated_key); + let original_config = fs::read(&config_path).unwrap(); + let mut client = FakeCodexHooksClient { + hook_lists: VecDeque::from([Ok(Vec::new())]), + ..FakeCodexHooksClient::default() + }; + + let error = uninstall_codex_with_client(DEFAULT_URL, Some(&mut client)).unwrap_err(); + + assert!(error.contains("did not clear trust"), "{error}"); + assert_eq!(fs::read(&config_path).unwrap(), original_config); + assert_eq!(client.restored.len(), 1); + assert_eq!( + client.restored[0] + .iter() + .map(|(key, _)| key.clone()) + .collect::>(), + relay_keys.into_iter().collect::>() + ); + assert!(client.restored[0].iter().all(|(_, value)| value.is_some())); +} + +#[test] +fn codex_uninstall_restores_files_even_when_trust_cleanup_fails() { + let dir = tempdir().unwrap(); + let _home = HomeScope::enter(dir.path()); + let codex_dir = dir.path().join(".codex"); + fs::create_dir_all(&codex_dir).unwrap(); + let config_path = codex_dir.join("config.toml"); + let hooks_path = codex_dir.join("hooks.json"); + fs::write(&config_path, "model_provider = \"openai\"\n").unwrap(); + fs::write(&hooks_path, "{}\n").unwrap(); + install_codex_hooks(&hooks_path, DEFAULT_URL).unwrap(); + install_codex_config(&config_path, DEFAULT_URL).unwrap(); + let original_config = fs::read(&config_path).unwrap(); + let original_config_backup = fs::read(backup_path(&config_path)).unwrap(); + let original_hooks = fs::read(&hooks_path).unwrap(); + let original_hooks_backup = fs::read(backup_path(&hooks_path)).unwrap(); + let original_metadata = required_codex_hook_metadata(&hooks_path, "trusted", true); + let mut client = FakeCodexHooksClient { + hook_lists: VecDeque::from([Ok(original_metadata.clone()), Ok(original_metadata)]), + clear_error: Some("config is locked".into()), + ..FakeCodexHooksClient::default() + }; + + let error = uninstall_codex_with_client(DEFAULT_URL, Some(&mut client)).unwrap_err(); + + assert!(error.contains("config is locked"), "{error}"); + assert_eq!(fs::read(&config_path).unwrap(), original_config); + assert_eq!( + fs::read(backup_path(&config_path)).unwrap(), + original_config_backup + ); + assert_eq!(fs::read(&hooks_path).unwrap(), original_hooks); + assert_eq!( + fs::read(backup_path(&hooks_path)).unwrap(), + original_hooks_backup + ); + assert_eq!(client.restored.len(), 1); +} + +#[test] +fn codex_uninstall_requires_trust_client_before_mutating_files() { + let dir = tempdir().unwrap(); + let _home = HomeScope::enter(dir.path()); + let codex_dir = dir.path().join(".codex"); + fs::create_dir_all(&codex_dir).unwrap(); + let config_path = codex_dir.join("config.toml"); + let hooks_path = codex_dir.join("hooks.json"); + fs::write(&config_path, "model_provider = \"openai\"\n").unwrap(); + fs::write(&hooks_path, "{\"custom\":true}\n").unwrap(); + let original_config = fs::read(&config_path).unwrap(); + let original_hooks = fs::read(&hooks_path).unwrap(); + + let error = uninstall_codex_with_client(DEFAULT_URL, None).unwrap_err(); + + assert!(error.contains("app-server is required"), "{error}"); + assert_eq!(fs::read(&config_path).unwrap(), original_config); + assert_eq!(fs::read(&hooks_path).unwrap(), original_hooks); +} + +#[test] +fn codex_uninstall_rolls_back_when_trust_cleanup_cannot_be_verified() { + let dir = tempdir().unwrap(); + let _home = HomeScope::enter(dir.path()); + let codex_dir = dir.path().join(".codex"); + fs::create_dir_all(&codex_dir).unwrap(); + let config_path = codex_dir.join("config.toml"); + let hooks_path = codex_dir.join("hooks.json"); + fs::write(&config_path, "model_provider = \"openai\"\n").unwrap(); + fs::write(&hooks_path, "{}\n").unwrap(); + install_codex_hooks(&hooks_path, DEFAULT_URL).unwrap(); + install_codex_config(&config_path, DEFAULT_URL).unwrap(); + let original_config = fs::read(&config_path).unwrap(); + let original_hooks = fs::read(&hooks_path).unwrap(); + let trusted = required_codex_hook_metadata(&hooks_path, "trusted", true); + let mut client = FakeCodexHooksClient { + hook_lists: VecDeque::from([Ok(trusted.clone()), Ok(trusted.clone()), Ok(trusted)]), + ..FakeCodexHooksClient::default() + }; + + let error = uninstall_codex_with_client(DEFAULT_URL, Some(&mut client)).unwrap_err(); + + assert!(error.contains("did not clear trust"), "{error}"); + assert_eq!(fs::read(&config_path).unwrap(), original_config); + assert_eq!(fs::read(&hooks_path).unwrap(), original_hooks); + assert_eq!(client.restored.len(), 1); +} + +#[test] +fn codex_uninstall_with_backup_preserves_user_changed_model_provider() { + let dir = tempdir().unwrap(); + let path = dir.path().join("config.toml"); + fs::write(&path, "model_provider = \"openai\"\n").unwrap(); + install_codex_config(&path, DEFAULT_URL).unwrap(); + fs::write( + &path, + r#" +model_provider = "local" + +[features] +hooks = true + +[model_providers.nemo-relay-openai] +name = "NeMo Relay" +base_url = "http://127.0.0.1:47632" +wire_api = "responses" +requires_openai_auth = true +supports_websockets = false +"#, + ) + .unwrap(); + + uninstall_codex_config(&path, DEFAULT_URL, false).unwrap(); + let updated = fs::read_to_string(&path).unwrap(); + + assert!(updated.contains("model_provider = \"local\"")); + assert!(!updated.contains("nemo-relay-openai")); + assert!(!backup_path(&path).exists()); +} + +#[test] +fn codex_uninstall_with_backup_preserves_user_changed_provider_table() { + let dir = tempdir().unwrap(); + let path = dir.path().join("config.toml"); + fs::write(&path, "model_provider = \"openai\"\n").unwrap(); + install_codex_config(&path, DEFAULT_URL).unwrap(); + fs::write( + &path, + r#" +model_provider = "nemo-relay-openai" + +[features] +hooks = true + +[model_providers.nemo-relay-openai] +name = "Custom Relay" +base_url = "http://127.0.0.1:47632" +wire_api = "responses" +requires_openai_auth = true +supports_websockets = false +"#, + ) + .unwrap(); + + uninstall_codex_config(&path, DEFAULT_URL, false).unwrap(); + let updated = fs::read_to_string(&path).unwrap(); + + assert!(updated.contains("model_provider = \"nemo-relay-openai\"")); + assert!(updated.contains("name = \"Custom Relay\"")); + assert!(updated.contains("nemo-relay-openai")); + assert!(!backup_path(&path).exists()); +} + +#[test] +fn codex_uninstall_preserves_user_changed_provider_url() { + let dir = tempdir().unwrap(); + let path = dir.path().join("config.toml"); + fs::write(&path, "model_provider = \"openai\"\n").unwrap(); + install_codex_config(&path, DEFAULT_URL).unwrap(); + fs::write( + &path, + r#" +model_provider = "nemo-relay-openai" + +[features] +hooks = true + +[model_providers.nemo-relay-openai] +name = "NeMo Relay" +base_url = "http://127.0.0.1:49999" +wire_api = "responses" +requires_openai_auth = true +supports_websockets = false +"#, + ) + .unwrap(); + + uninstall_codex_config(&path, DEFAULT_URL, false).unwrap(); + let updated = fs::read_to_string(&path).unwrap(); + + assert!(updated.contains("model_provider = \"nemo-relay-openai\"")); + assert!(updated.contains("base_url = \"http://127.0.0.1:49999\"")); + assert!(!backup_path(&path).exists()); +} + +#[test] +fn codex_uninstall_removes_proof_from_a_user_modified_provider() { + let dir = tempdir().unwrap(); + let _home = HomeScope::enter(dir.path()); + let path = dir.path().join(".codex/config.toml"); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(&path, "model_provider = \"openai\"\n").unwrap(); + install_codex_config(&path, DEFAULT_URL).unwrap(); + let installed = fs::read_to_string(&path).unwrap(); + assert!(installed.contains(BOOTSTRAP_CLIENT_TOKEN_HEADER)); + let modified = installed.replacen(DEFAULT_URL, "http://127.0.0.1:49999", 1); + assert_ne!(installed, modified); + fs::write(&path, modified).unwrap(); + + uninstall_codex_config(&path, DEFAULT_URL, false).unwrap(); + let updated = fs::read_to_string(&path).unwrap(); + + assert!(updated.contains("base_url = \"http://127.0.0.1:49999\"")); + assert!(!updated.contains(BOOTSTRAP_CLIENT_TOKEN_HEADER)); + assert!(!backup_path(&path).exists()); +} + +#[test] +fn codex_uninstall_without_backup_preserves_user_changed_provider_url() { + let dir = tempdir().unwrap(); + let path = dir.path().join("config.toml"); + fs::write( + &path, + r#" +model_provider = "nemo-relay-openai" + +[features] +hooks = true + +[model_providers.nemo-relay-openai] +name = "NeMo Relay" +base_url = "http://127.0.0.1:49999" +wire_api = "responses" +requires_openai_auth = true +supports_websockets = false +"#, + ) + .unwrap(); + + uninstall_codex_config(&path, DEFAULT_URL, false).unwrap(); + let updated = fs::read_to_string(&path).unwrap(); + + assert!(updated.contains("model_provider = \"nemo-relay-openai\"")); + assert!(updated.contains("base_url = \"http://127.0.0.1:49999\"")); +} + +#[test] +fn codex_uninstall_without_backup_preserves_user_hooks_when_provider_is_not_managed() { + let dir = tempdir().unwrap(); + let path = dir.path().join("config.toml"); + fs::write( + &path, + r#" +model_provider = "openai" + +[features] +hooks = true +"#, + ) + .unwrap(); + + uninstall_codex_config(&path, DEFAULT_URL, false).unwrap(); + let updated = fs::read_to_string(&path).unwrap(); + + assert!(updated.contains("hooks = true")); +} + +#[test] +fn codex_uninstall_preserves_hooks_feature_when_user_hooks_remain() { + let dir = tempdir().unwrap(); + let _home = HomeScope::enter(dir.path()); + let codex_dir = dir.path().join(".codex"); + fs::create_dir_all(&codex_dir).unwrap(); + fs::write( + codex_dir.join("config.toml"), + r#" +model_provider = "openai" + +[features] +hooks = false +"#, + ) + .unwrap(); + + let hooks_path = codex_dir.join("hooks.json"); + fs::write( + &hooks_path, + serde_json::to_vec_pretty(&json!({ + "hooks": {"SessionStart": [{ + "hooks": [ + { + "type": "command", + "command": "custom-hook", + "timeout": 30 + } + ] + }]}})) + .unwrap(), + ) + .unwrap(); + install_codex_with_trust( + DEFAULT_URL, + &expected_plugin_command(), + |_home, _config, _command| Ok(()), + ) + .unwrap(); + + let mut client = empty_codex_hooks_client(); + uninstall_codex_with_client(DEFAULT_URL, Some(&mut client)).unwrap(); + + let updated_config = fs::read_to_string(codex_dir.join("config.toml")).unwrap(); + assert!(updated_config.contains("hooks = true")); + let updated_hooks: Value = + serde_json::from_str(&fs::read_to_string(&hooks_path).unwrap()).unwrap(); + assert!(event_contains_command( + &updated_hooks, + "SessionStart", + "custom-hook" + )); + assert!( + !serde_json::to_string(&updated_hooks) + .unwrap() + .contains("plugin-shim hook codex") + ); +} + +#[test] +fn codex_reinstall_uses_fresh_backup_after_prior_uninstall() { + let dir = tempdir().unwrap(); + let path = dir.path().join("config.toml"); + fs::write(&path, "model_provider = \"openai\"\n").unwrap(); + + install_codex_config(&path, DEFAULT_URL).unwrap(); + uninstall_codex_config(&path, DEFAULT_URL, false).unwrap(); + assert!(!backup_path(&path).exists()); + + fs::write(&path, "model_provider = \"local\"\n").unwrap(); + install_codex_config(&path, DEFAULT_URL).unwrap(); + uninstall_codex_config(&path, DEFAULT_URL, false).unwrap(); + + assert_eq!( + fs::read_to_string(&path).unwrap(), + "model_provider = \"local\"\n" + ); + assert!(!backup_path(&path).exists()); +} + +#[test] +fn claude_restore_without_backup_preserves_matching_user_relay_url() { + let dir = tempdir().unwrap(); + let _home = HomeScope::enter(dir.path()); + let settings = dir.path().join(".claude").join("settings.json"); + fs::create_dir_all(settings.parent().unwrap()).unwrap(); + fs::write( + &settings, + serde_json::to_vec_pretty(&json!({ + "env": { + "ANTHROPIC_BASE_URL": DEFAULT_URL, + "OTHER": "kept" + } + })) + .unwrap(), + ) + .unwrap(); + + restore_claude_provider(DEFAULT_URL).unwrap(); + + let updated: Value = serde_json::from_str(&fs::read_to_string(&settings).unwrap()).unwrap(); + assert_eq!( + json_env_string(&updated, "ANTHROPIC_BASE_URL"), + Some(DEFAULT_URL) + ); + assert_eq!(json_env_string(&updated, "OTHER"), Some("kept")); + assert!(!backup_path(&settings).exists()); +} + +#[test] +fn claude_enable_rolls_back_backup_when_settings_write_fails() { + let dir = tempdir().unwrap(); + let _home = HomeScope::enter(dir.path()); + let settings = dir.path().join(".claude").join("settings.json"); + fs::create_dir_all(settings.parent().unwrap()).unwrap(); + fs::write( + &settings, + serde_json::to_vec_pretty(&json!({ + "env": { + "ANTHROPIC_BASE_URL": "https://api.anthropic.com" + } + })) + .unwrap(), + ) + .unwrap(); + crate::filesystem::fail_next_atomic_write(&settings); + + let error = enable_claude_provider(DEFAULT_URL).unwrap_err(); + + assert!(error.contains("failed to write")); + assert!(!backup_path(&settings).exists()); +} + +#[test] +fn claude_enable_does_not_back_up_when_env_shape_is_invalid() { + let dir = tempdir().unwrap(); + let _home = HomeScope::enter(dir.path()); + let settings = dir.path().join(".claude").join("settings.json"); + fs::create_dir_all(settings.parent().unwrap()).unwrap(); + fs::write( + &settings, + serde_json::to_vec_pretty(&json!({ + "env": "invalid" + })) + .unwrap(), + ) + .unwrap(); + + let error = enable_claude_provider(DEFAULT_URL).unwrap_err(); + + assert!(error.contains("non-object env field")); + assert!(!backup_path(&settings).exists()); + let unchanged: Value = serde_json::from_str(&fs::read_to_string(&settings).unwrap()).unwrap(); + assert_eq!(unchanged["env"], json!("invalid")); +} + +#[test] +fn claude_restore_with_backup_preserves_user_settings_added_after_install() { + let dir = tempdir().unwrap(); + let _home = HomeScope::enter(dir.path()); + let settings = dir.path().join(".claude").join("settings.json"); + fs::create_dir_all(settings.parent().unwrap()).unwrap(); + fs::write( + &settings, + serde_json::to_vec_pretty(&json!({ + "env": { + "ANTHROPIC_BASE_URL": "https://api.anthropic.com", + "ORIGINAL": "kept" + } + })) + .unwrap(), + ) + .unwrap(); + enable_claude_provider(DEFAULT_URL).unwrap(); + fs::write( + &settings, + serde_json::to_vec_pretty(&json!({ + "env": { + "ANTHROPIC_BASE_URL": DEFAULT_URL, + "ORIGINAL": "updated", + "ADDED": "kept" + }, + "theme": "dark" + })) + .unwrap(), + ) + .unwrap(); + + restore_claude_provider(DEFAULT_URL).unwrap(); + + let updated: Value = serde_json::from_str(&fs::read_to_string(&settings).unwrap()).unwrap(); + assert_eq!( + json_env_string(&updated, "ANTHROPIC_BASE_URL"), + Some("https://api.anthropic.com") + ); + assert_eq!(json_env_string(&updated, "ORIGINAL"), Some("updated")); + assert_eq!(json_env_string(&updated, "ADDED"), Some("kept")); + assert_eq!(updated["theme"], json!("dark")); + assert!(!backup_path(&settings).exists()); +} + +#[test] +fn claude_restore_with_backup_preserves_user_changed_provider_url() { + let dir = tempdir().unwrap(); + let _home = HomeScope::enter(dir.path()); + let settings = dir.path().join(".claude").join("settings.json"); + fs::create_dir_all(settings.parent().unwrap()).unwrap(); + fs::write( + &settings, + serde_json::to_vec_pretty(&json!({ + "env": { + "ANTHROPIC_BASE_URL": "https://api.anthropic.com" + } + })) + .unwrap(), + ) + .unwrap(); + enable_claude_provider(DEFAULT_URL).unwrap(); + fs::write( + &settings, + serde_json::to_vec_pretty(&json!({ + "env": { + "ANTHROPIC_BASE_URL": "http://127.0.0.1:49999" + } + })) + .unwrap(), + ) + .unwrap(); + + restore_claude_provider(DEFAULT_URL).unwrap(); + + let updated: Value = serde_json::from_str(&fs::read_to_string(&settings).unwrap()).unwrap(); + assert_eq!( + json_env_string(&updated, "ANTHROPIC_BASE_URL"), + Some("http://127.0.0.1:49999") + ); + assert!(backup_path(&settings).exists()); +} + +#[test] +fn claude_reinstall_refreshes_backup_after_user_owned_restore() { + let dir = tempdir().unwrap(); + let _home = HomeScope::enter(dir.path()); + let settings = dir.path().join(".claude").join("settings.json"); + fs::create_dir_all(settings.parent().unwrap()).unwrap(); + fs::write( + &settings, + serde_json::to_vec_pretty(&json!({ + "env": { + "ANTHROPIC_BASE_URL": "https://api.anthropic.com" + } + })) + .unwrap(), + ) + .unwrap(); + + enable_claude_provider(DEFAULT_URL).unwrap(); + fs::write( + &settings, + serde_json::to_vec_pretty(&json!({ + "env": { + "ANTHROPIC_BASE_URL": "https://custom.example" + } + })) + .unwrap(), + ) + .unwrap(); + restore_claude_provider(DEFAULT_URL).unwrap(); + assert!(backup_path(&settings).exists()); + + enable_claude_provider(DEFAULT_URL).unwrap(); + let refreshed_backup: Value = + serde_json::from_str(&fs::read_to_string(backup_path(&settings)).unwrap()).unwrap(); + assert_eq!( + json_env_string(&refreshed_backup, "ANTHROPIC_BASE_URL"), + Some("https://custom.example") + ); + + restore_claude_provider(DEFAULT_URL).unwrap(); + + let updated: Value = serde_json::from_str(&fs::read_to_string(&settings).unwrap()).unwrap(); + assert_eq!( + json_env_string(&updated, "ANTHROPIC_BASE_URL"), + Some("https://custom.example") + ); + assert!(!backup_path(&settings).exists()); +} + +#[test] +fn claude_reinstall_uses_fresh_backup_after_prior_restore() { + let dir = tempdir().unwrap(); + let _home = HomeScope::enter(dir.path()); + let settings = dir.path().join(".claude").join("settings.json"); + fs::create_dir_all(settings.parent().unwrap()).unwrap(); + fs::write( + &settings, + serde_json::to_vec_pretty(&json!({ + "env": { + "ANTHROPIC_BASE_URL": "https://api.anthropic.com" + } + })) + .unwrap(), + ) + .unwrap(); + + enable_claude_provider(DEFAULT_URL).unwrap(); + restore_claude_provider(DEFAULT_URL).unwrap(); + assert!(!backup_path(&settings).exists()); + + fs::write( + &settings, + serde_json::to_vec_pretty(&json!({ + "env": { + "ANTHROPIC_BASE_URL": "https://custom.example" + } + })) + .unwrap(), + ) + .unwrap(); + + enable_claude_provider(DEFAULT_URL).unwrap(); + restore_claude_provider(DEFAULT_URL).unwrap(); + + let updated: Value = serde_json::from_str(&fs::read_to_string(&settings).unwrap()).unwrap(); + assert_eq!( + json_env_string(&updated, "ANTHROPIC_BASE_URL"), + Some("https://custom.example") + ); + assert!(!backup_path(&settings).exists()); +} + +#[test] +fn claude_gateway_url_change_preserves_the_pre_relay_backup() { + let dir = tempdir().unwrap(); + let _home = HomeScope::enter(dir.path()); + let settings = dir.path().join(".claude").join("settings.json"); + fs::create_dir_all(settings.parent().unwrap()).unwrap(); + fs::write( + &settings, + serde_json::to_vec_pretty(&json!({ + "env": { "ANTHROPIC_BASE_URL": "https://api.anthropic.com" } + })) + .unwrap(), + ) + .unwrap(); + + enable_claude_provider(DEFAULT_URL).unwrap(); + let replacement_gateway = "http://127.0.0.1:49999"; + enable_claude_provider(replacement_gateway).unwrap(); + restore_claude_provider(replacement_gateway).unwrap(); + + let restored: Value = serde_json::from_slice(&fs::read(&settings).unwrap()).unwrap(); + assert_eq!( + json_env_string(&restored, "ANTHROPIC_BASE_URL"), + Some("https://api.anthropic.com") + ); + assert!(!backup_path(&settings).exists()); +} + +#[test] +fn windows_shell_argument_quoting_and_hook_encoding_preserve_paths() { + let relay = std::path::PathBuf::from(r"C:\Program Files\NeMo 100%\bin\nemo-relay.exe"); + let generation = + std::path::PathBuf::from(r"C:\Program Files\NeMo 100%\plugin\.nemo-relay-generation"); + assert_eq!( + shell_quote_arg_for_platform(relay.to_str().unwrap(), true), + r#""C:\Program Files\NeMo 100%%cd:~,%\bin\nemo-relay.exe""# + ); + assert_eq!( + crate::hooks::decode_windows_hook_command(&codex_plugin_hook_command_for_platform( + &relay, + &generation, + "test-generation", + true, + )) + .unwrap(), + vec![ + relay.display().to_string(), + "hook-forward".into(), + "codex".into(), + "--gateway-url".into(), + DEFAULT_URL.into(), + "--generation-file".into(), + generation.display().to_string(), + "--generation-token".into(), + "test-generation".into(), + ] + ); + assert_eq!( + shell_quote_arg_for_platform("foo&bar", true), + r#""foo&bar""# + ); + assert_eq!(shell_quote_arg_for_platform("", true), r#""""#); +} + +#[cfg(windows)] +#[test] +fn generated_windows_hook_command_executes_exact_arguments() { + let temp = tempfile::tempdir().unwrap(); + let bin = temp.path().join("Relay & %USERPROFILE% !^ Tools"); + std::fs::create_dir(&bin).unwrap(); + let relay = bin.join("nemo-relay.exe"); + compile_windows_hook_test_relay(&relay); + let marker = temp.path().join("hook-ran.txt"); + let input_marker = temp.path().join("hook-input.txt"); + let generation = temp.path().join("Generation & %USERPROFILE%"); + let command = codex_plugin_hook_command(&relay, &generation, "test-generation").unwrap(); + let mut child = std::process::Command::new("cmd.exe") + .arg("/C") + .arg(&command) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .env("NEMO_RELAY_HOOK_MARKER", &marker) + .env("NEMO_RELAY_HOOK_INPUT_MARKER", &input_marker) + .env("NEMO_RELAY_HOOK_GENERATION", &generation) + .env("NEMO_RELAY_HOOK_EMIT_OUTPUT", "1") + .spawn() + .unwrap(); + use std::io::Write; + child.stdin.take().unwrap().write_all(b"ping\n").unwrap(); + let output = child.wait_with_output().unwrap(); + + assert!(output.status.success(), "{command}"); + assert_eq!(std::fs::read_to_string(marker).unwrap().trim(), "ok"); + assert_eq!(std::fs::read(input_marker).unwrap(), b"ping\n"); + assert_eq!( + String::from_utf8_lossy(&output.stdout).trim(), + "hook-stdout" + ); + assert_eq!( + String::from_utf8_lossy(&output.stderr).trim(), + "hook-stderr" + ); +} + +#[cfg(windows)] +#[test] +fn generated_windows_hook_command_propagates_the_relay_exit_code() { + let temp = tempfile::tempdir().unwrap(); + let relay = temp.path().join("relay failure.exe"); + compile_windows_hook_test_relay(&relay); + let generation = temp.path().join("generation"); + let command = codex_plugin_hook_command(&relay, &generation, "test-generation").unwrap(); + + let status = std::process::Command::new("cmd.exe") + .arg("/C") + .arg(&command) + .env("NEMO_RELAY_HOOK_GENERATION", &generation) + .env("NEMO_RELAY_HOOK_EXIT_CODE", "23") + .status() + .unwrap(); + + assert_eq!(status.code(), Some(23), "{command}"); +} + +#[cfg(windows)] +fn compile_windows_hook_test_relay(output: &Path) { + let source = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/windows_hook_relay.rs"); + let rustc = std::env::var_os("RUSTC").unwrap_or_else(|| OsString::from("rustc")); + let compiled = std::process::Command::new(rustc) + .arg(source) + .args(["--edition", "2024", "-o"]) + .arg(output) + .output() + .unwrap(); + assert!( + compiled.status.success(), + "failed to compile native hook fixture: {}", + String::from_utf8_lossy(&compiled.stderr) + ); +} + +#[test] +fn posix_shell_argument_quoting_and_hook_encoding_preserve_paths() { + let relay = std::path::PathBuf::from("/tmp/NeMo $Relay`test'/bin/nemo-relay"); + let generation = + std::path::PathBuf::from("/tmp/NeMo $Relay`test'/plugin/.nemo-relay-generation"); + assert_eq!( + shell_quote_arg_for_platform(relay.to_str().unwrap(), false), + "'/tmp/NeMo $Relay`test'\\''/bin/nemo-relay'" + ); + assert_eq!( + codex_plugin_hook_command_for_platform(&relay, &generation, "test-generation", false), + "'/tmp/NeMo $Relay`test'\\''/bin/nemo-relay' hook-forward codex --gateway-url http://127.0.0.1:47632 --generation-file '/tmp/NeMo $Relay`test'\\''/plugin/.nemo-relay-generation' --generation-token test-generation" + ); + assert_eq!(shell_quote_arg_for_platform("", false), "''"); + assert_eq!( + shell_quote_arg_for_platform(r"/tmp/path\with-backslash", false), + r#"'/tmp/path\with-backslash'"# + ); +} + +#[test] +fn healthz_rejects_foreign_success_response() { + let dir = tempdir().unwrap(); + let _home = HomeScope::enter(dir.path()); + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let handle = thread::spawn(move || { + for _ in 0..2 { + let (mut stream, _) = listener.accept().unwrap(); + let _ = read_http_request(&mut stream); + stream + .write_all( + b"HTTP/1.1 200 OK\r\nContent-Length: 15\r\nConnection: close\r\n\r\n{\"status\":\"ok\"}", + ) + .unwrap(); + } + }); + + let error = GatewaySpec::new(address) + .acquire() + .expect_err("foreign listener unexpectedly acquired"); + assert!( + error.contains("not a compatible NeMo Relay gateway"), + "{error}" + ); + handle.join().unwrap(); +} + +#[test] +fn codex_uninstall_removes_only_exact_generated_hook_groups() { + let dir = tempdir().unwrap(); + let path = dir.path().join("hooks.json"); + let command = codex_hook_command("http://127.0.0.1:47633"); + let generated = generated_hooks(CodingAgent::Codex, &command); + let user_command = "custom-user-codex-hook"; + let config = json!({ + "hooks": { + "SessionStart": [ + generated["hooks"]["SessionStart"][0].clone(), + { + "hooks": [ + { + "type": "command", + "command": user_command, + "timeout": 30 + } + ] + } + ] + } + }); + fs::write(&path, serde_json::to_vec_pretty(&config).unwrap()).unwrap(); + + uninstall_codex_hooks(&path, "http://127.0.0.1:47633").unwrap(); + let updated: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap(); + + assert!(event_contains_command( + &updated, + "SessionStart", + user_command + )); + assert!(!generated_event_contains_group( + &updated, + "SessionStart", + &generated["hooks"]["SessionStart"][0] + )); +} + +#[test] +fn codex_install_hooks_removes_prior_non_default_generated_url() { + let dir = tempdir().unwrap(); + let path = dir.path().join("hooks.json"); + let old_command = codex_hook_command("http://127.0.0.1:47633"); + let new_command = codex_hook_command("http://127.0.0.1:47634"); + + install_codex_hooks(&path, "http://127.0.0.1:47633").unwrap(); + install_codex_hooks(&path, "http://127.0.0.1:47634").unwrap(); + let updated: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap(); + + assert!(!event_contains_command( + &updated, + "SessionStart", + &old_command + )); + assert!(event_contains_command( + &updated, + "SessionStart", + &new_command + )); +} + +#[test] +fn codex_uninstall_hooks_removes_all_generated_url_variants_for_launcher() { + let dir = tempdir().unwrap(); + let path = dir.path().join("hooks.json"); + let old_command = codex_hook_command("http://127.0.0.1:47633"); + let new_command = codex_hook_command("http://127.0.0.1:47634"); + let mut old_generated = generated_hooks(CodingAgent::Codex, &old_command); + let new_generated = generated_hooks(CodingAgent::Codex, &new_command); + old_generated["hooks"]["SessionStart"] + .as_array_mut() + .unwrap() + .push(new_generated["hooks"]["SessionStart"][0].clone()); + fs::write(&path, serde_json::to_vec_pretty(&old_generated).unwrap()).unwrap(); + + uninstall_codex_hooks(&path, "http://127.0.0.1:47634").unwrap(); + let updated: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap(); + + assert!(!event_contains_command( + &updated, + "SessionStart", + &old_command + )); + assert!(!event_contains_command( + &updated, + "SessionStart", + &new_command + )); +} + +#[test] +fn codex_install_hooks_persist_custom_gateway_url() { + let dir = tempdir().unwrap(); + let path = dir.path().join("hooks.json"); + + install_codex_hooks(&path, "http://127.0.0.1:47633").unwrap(); + let updated: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap(); + let command = updated["hooks"]["SessionStart"][0]["hooks"][0]["command"] + .as_str() + .unwrap(); + + assert!(crate::hook_assertions::command_has_arguments( + command, + &[ + "hook-forward", + "codex", + "--gateway-url", + "http://127.0.0.1:47633", + ] + )); +} + +#[test] +fn codex_install_migration_removes_legacy_relay_groups_and_preserves_unrelated_hooks() { + let dir = tempdir().unwrap(); + let path = dir.path().join("hooks.json"); + let relay = current_exe().unwrap(); + let legacy_command = legacy_codex_hook_command(&relay); + let mut legacy = generated_hooks(CodingAgent::Codex, &legacy_command); + legacy["hooks"]["SessionStart"] + .as_array_mut() + .unwrap() + .push(json!({ + "hooks": [{ + "type": "command", + "command": "custom-user-hook", + "timeout": 30 + }] + })); + let original = serde_json::to_vec_pretty(&legacy).unwrap(); + fs::write(&path, &original).unwrap(); + + remove_legacy_codex_hooks(&path).unwrap(); + let updated: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap(); + + assert!(!event_contains_command( + &updated, + "SessionStart", + &legacy_command + )); + assert!(event_contains_command( + &updated, + "SessionStart", + "custom-user-hook" + )); + assert_eq!(fs::read(backup_path(&path)).unwrap(), original); +} + +#[test] +fn codex_migration_removes_modified_relay_handler_from_mixed_user_group() { + let dir = tempdir().unwrap(); + let path = dir.path().join("hooks.json"); + let legacy_command = + "'/old install/nemo-relay' plugin-shim hook codex --gateway-url http://127.0.0.1:47632"; + write_json( + &path, + &json!({ + "hooks": { + "SessionStart": [{ + "hooks": [ + {"type": "command", "command": legacy_command, "timeout": 60}, + {"type": "command", "command": "custom-user-hook", "timeout": 45} + ] + }] + } + }), + ) + .unwrap(); + + remove_legacy_codex_hooks(&path).unwrap(); + let updated = read_json_object(&path).unwrap(); + + assert!(!event_contains_command( + &updated, + "SessionStart", + legacy_command + )); + assert!(event_contains_command( + &updated, + "SessionStart", + "custom-user-hook" + )); +} + +#[test] +fn codex_install_does_not_write_provider_config_when_hooks_are_invalid() { + let dir = tempdir().unwrap(); + let _home = HomeScope::enter(dir.path()); + let codex_dir = dir.path().join(".codex"); + fs::create_dir_all(&codex_dir).unwrap(); + fs::write( + codex_dir.join("config.toml"), + "model_provider = \"openai\"\n", + ) + .unwrap(); + let plugin_hooks = dir.path().join("plugin").join("hooks").join("hooks.json"); + fs::create_dir_all(plugin_hooks.parent().unwrap()).unwrap(); + write_plugin_generation_for_hooks(&plugin_hooks); + fs::write(&plugin_hooks, "{ invalid json").unwrap(); + + let error = install_codex(DEFAULT_URL, &plugin_hooks).unwrap_err(); + assert!(error.contains("invalid JSON")); + + assert_eq!( + fs::read_to_string(codex_dir.join("config.toml")).unwrap(), + "model_provider = \"openai\"\n" + ); + assert!(!backup_path(&codex_dir.join("config.toml")).exists()); +} + +#[test] +fn codex_install_does_not_write_hooks_when_config_is_invalid() { + let dir = tempdir().unwrap(); + let _home = HomeScope::enter(dir.path()); + let codex_dir = dir.path().join(".codex"); + fs::create_dir_all(&codex_dir).unwrap(); + fs::write(codex_dir.join("config.toml"), "model_provider = [").unwrap(); + let hooks_path = codex_dir.join("hooks.json"); + let original_hooks = serde_json::to_vec_pretty(&json!({ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "custom-hook", + "timeout": 30 + } + ] + } + ] + } + })) + .unwrap(); + fs::write(&hooks_path, &original_hooks).unwrap(); + + let error = install_codex_with_trust( + DEFAULT_URL, + &expected_plugin_command(), + |_home, _config, _command| Err("expected exactly one Relay handler".into()), + ) + .unwrap_err(); + assert!(error.contains("invalid TOML")); + + assert_eq!(fs::read(&hooks_path).unwrap(), original_hooks); + assert!(!backup_path(&hooks_path).exists()); +} + +#[test] +fn codex_install_does_not_write_hooks_when_config_is_not_readable() { + let dir = tempdir().unwrap(); + let _home = HomeScope::enter(dir.path()); + let codex_dir = dir.path().join(".codex"); + fs::create_dir_all(&codex_dir).unwrap(); + fs::create_dir(codex_dir.join("config.toml")).unwrap(); + let hooks_path = codex_dir.join("hooks.json"); + let original_hooks = serde_json::to_vec_pretty(&json!({ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "custom-hook", + "timeout": 30 + } + ] + } + ] + } + })) + .unwrap(); + fs::write(&hooks_path, &original_hooks).unwrap(); + + let error = install_codex_with_trust( + DEFAULT_URL, + &expected_plugin_command(), + |_home, _config, _command| Err("expected exactly one Relay handler".into()), + ) + .unwrap_err(); + assert!(error.contains("failed to read")); + + assert_eq!(fs::read(&hooks_path).unwrap(), original_hooks); + assert!(!backup_path(&hooks_path).exists()); +} + +#[test] +fn codex_install_config_rolls_back_backup_when_write_fails() { + let dir = tempdir().unwrap(); + let path = dir.path().join("config.toml"); + fs::write(&path, "model_provider = \"openai\"\n").unwrap(); + crate::filesystem::fail_next_atomic_write(&path); + + let error = install_codex_config(&path, DEFAULT_URL).unwrap_err(); + + assert!(error.contains("failed to write")); + assert!(!backup_path(&path).exists()); +} + +#[test] +fn codex_install_preserves_invalid_user_hooks_when_trust_fails() { + let dir = tempdir().unwrap(); + let _home = HomeScope::enter(dir.path()); + let codex_dir = dir.path().join(".codex"); + fs::create_dir_all(&codex_dir).unwrap(); + fs::write( + codex_dir.join("config.toml"), + "model_provider = \"openai\"\n", + ) + .unwrap(); + let hooks_path = codex_dir.join("hooks.json"); + let original_hooks = serde_json::to_vec_pretty(&json!({ + "hooks": { + "SessionStart": "invalid" + } + })) + .unwrap(); + fs::write(&hooks_path, &original_hooks).unwrap(); + + let error = install_codex_with_trust( + DEFAULT_URL, + &expected_plugin_command(), + |_home, _config, _command| Err("expected exactly one Relay handler".into()), + ) + .unwrap_err(); + + assert!(error.contains("exactly one Relay handler"), "{error}"); + assert_eq!(fs::read(&hooks_path).unwrap(), original_hooks); + assert!(!backup_path(&hooks_path).exists()); + assert_eq!( + fs::read_to_string(codex_dir.join("config.toml")).unwrap(), + "model_provider = \"openai\"\n" + ); +} + +#[test] +fn codex_uninstall_rolls_back_hooks_when_provider_config_is_invalid() { + let dir = tempdir().unwrap(); + let _home = HomeScope::enter(dir.path()); + let codex_dir = dir.path().join(".codex"); + fs::create_dir_all(&codex_dir).unwrap(); + fs::write(codex_dir.join("config.toml"), "model_provider = [").unwrap(); + let hooks_path = codex_dir.join("hooks.json"); + install_codex_hooks(&hooks_path, DEFAULT_URL).unwrap(); + let original_hooks = fs::read(&hooks_path).unwrap(); + + let mut client = empty_codex_hooks_client(); + let error = uninstall_codex_with_client(DEFAULT_URL, Some(&mut client)).unwrap_err(); + + assert!(error.contains("invalid TOML")); + assert_eq!(fs::read(&hooks_path).unwrap(), original_hooks); +} + +#[test] +fn codex_install_rolls_back_hooks_when_provider_config_write_fails() { + let dir = tempdir().unwrap(); + let _home = HomeScope::enter(dir.path()); + let codex_dir = dir.path().join(".codex"); + fs::create_dir_all(&codex_dir).unwrap(); + fs::write( + codex_dir.join("config.toml"), + "model_provider = \"openai\"\n", + ) + .unwrap(); + crate::filesystem::fail_next_atomic_write(&codex_dir.join("config.toml")); + let hooks_path = codex_dir.join("hooks.json"); + let original_hooks = serde_json::to_vec_pretty(&json!({ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "custom-hook", + "timeout": 30 + } + ] + } + ] + } + })) + .unwrap(); + fs::write(&hooks_path, &original_hooks).unwrap(); + + let plugin_hooks = write_plugin_hooks(&dir.path().join("plugin")); + let error = install_codex(DEFAULT_URL, &plugin_hooks).unwrap_err(); + + assert!(error.contains("failed to write")); + assert_eq!(fs::read(&hooks_path).unwrap(), original_hooks); + assert!(!backup_path(&hooks_path).exists()); + assert_eq!( + fs::read_to_string(codex_dir.join("config.toml")).unwrap(), + "model_provider = \"openai\"\n" + ); +} + +#[test] +fn codex_uninstall_hooks_removes_legacy_generated_command() { + let dir = tempdir().unwrap(); + let path = dir.path().join("hooks.json"); + let relay = current_exe().unwrap(); + let legacy_command = legacy_codex_hook_command(&relay); + let legacy = generated_hooks(CodingAgent::Codex, &legacy_command); + fs::write(&path, serde_json::to_vec_pretty(&legacy).unwrap()).unwrap(); + + uninstall_codex_hooks(&path, DEFAULT_URL).unwrap(); + let updated: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap(); + + assert!(!event_contains_command( + &updated, + "SessionStart", + &legacy_command + )); +} + +#[test] +fn codex_provider_gateway_url_reads_managed_provider_url() { + let dir = tempdir().unwrap(); + let path = dir.path().join("config.toml"); + fs::write( + &path, + r#" +[model_providers.nemo-relay-openai] +base_url = "http://127.0.0.1:47633" +"#, + ) + .unwrap(); + + assert_eq!( + codex_provider_gateway_url(&path).as_deref(), + Some("http://127.0.0.1:47633") + ); +} + +#[test] +fn healthz_times_out_for_bad_port_occupant() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + let (accepted_sender, accepted_receiver) = std::sync::mpsc::channel(); + let (release_sender, release_receiver) = std::sync::mpsc::channel(); + let server = thread::spawn(move || { + let Ok((mut stream, _)) = listener.accept() else { + return; + }; + accepted_sender.send(()).unwrap(); + release_receiver + .recv_timeout(Duration::from_secs(5)) + .unwrap(); + let _ = stream.write_all(b"HTTP/1.1 200 OK\r\n\r\n"); + }); + let (result_sender, result_receiver) = std::sync::mpsc::channel(); + let health = thread::spawn(move || { + let result = healthz(&format!("http://127.0.0.1:{port}")); + result_sender.send(result).unwrap(); + }); + + accepted_receiver + .recv_timeout(Duration::from_secs(5)) + .unwrap(); + let result = result_receiver.recv_timeout(Duration::from_secs(5)); + release_sender.send(()).unwrap(); + server.join().unwrap(); + health.join().unwrap(); + assert!(!result.expect("health probe did not honor its read timeout")); +} + +#[test] +fn shared_json_helpers_cover_missing_invalid_and_non_object_inputs() { + let dir = tempdir().unwrap(); + let missing = dir.path().join("missing.json"); + assert_eq!(read_json_object(&missing).unwrap(), json!({})); + + let invalid = dir.path().join("invalid.json"); + fs::write(&invalid, "{not json").unwrap(); + assert!( + read_json_object(&invalid) + .unwrap_err() + .contains("invalid JSON") + ); + + let array = dir.path().join("array.json"); + fs::write(&array, "[]").unwrap(); + assert!( + read_json_object(&array) + .unwrap_err() + .contains("must contain a JSON object") + ); + + let nested = dir.path().join("nested").join("settings.json"); + write_json(&nested, &json!({"ok": true})).unwrap(); + assert_eq!( + fs::read_to_string(&nested).unwrap(), + "{\n \"ok\": true\n}\n" + ); +} + +#[test] +fn shared_filesystem_helpers_cover_tables_snapshots_and_lock_branches() { + let dir = tempdir().unwrap(); + let mut doc = "agent = \"codex\"\n" + .parse::() + .unwrap(); + ensure_table(&mut doc, "agent").insert("enabled", toml_edit::value(true)); + assert!(doc["agent"].is_table()); + assert_eq!(doc["agent"]["enabled"].as_bool(), Some(true)); + + let missing = dir.path().join("missing.txt"); + let snapshot = snapshot_optional_file(&missing).unwrap(); + fs::write(&missing, "created").unwrap(); + restore_file_snapshot(&snapshot).unwrap(); + assert!(!missing.exists()); + + let existing = dir.path().join("existing.txt"); + fs::write(&existing, "before").unwrap(); + let snapshot = snapshot_optional_file(&existing).unwrap(); + fs::write(&existing, "after").unwrap(); + restore_file_snapshot(&snapshot).unwrap(); + assert_eq!(fs::read_to_string(&existing).unwrap(), "before"); +} + +#[test] +fn shared_defaults_cover_idle_lifecycle_and_lock_names() { + let dir = tempdir().unwrap(); + let _home = HomeScope::enter(dir.path()); + let _plugin_url = EnvVarGuard::remove("NEMO_RELAY_PLUGIN_GATEWAY_URL"); + let _claude_url = EnvVarGuard::remove("NEMO_RELAY_GATEWAY_URL"); + let _timeout = EnvVarGuard::remove("NEMO_RELAY_PLUGIN_IDLE_TIMEOUT_SECS"); + let _fail_closed = EnvVarGuard::remove("NEMO_RELAY_FAIL_CLOSED"); + + assert_eq!(plugin_idle_timeout().unwrap(), Duration::from_secs(300)); + assert_eq!( + plugin_heartbeat_interval().unwrap(), + Duration::from_secs(30) + ); + assert_eq!(bootstrap_lock_name(""), "unknown"); +} + +#[test] +fn relay_binary_rejects_missing_override_and_uses_current_exe_fallback() { + let dir = tempdir().unwrap(); + let _home = HomeScope::enter(dir.path()); + let missing = dir.path().join("missing-nemo-relay"); + let _binary_override = EnvVarGuard::set_path("NEMO_RELAY_PLUGIN_BINARY", &missing); + assert!( + relay_binary() + .unwrap_err() + .contains("NEMO_RELAY_PLUGIN_BINARY does not exist") + ); + drop(_binary_override); + let _binary_override = EnvVarGuard::remove("NEMO_RELAY_PLUGIN_BINARY"); + assert!(relay_binary().unwrap().exists()); +} + +#[test] +fn claude_provider_enable_status_and_restore_cover_managed_backup_paths() { + let dir = tempdir().unwrap(); + let _home = HomeScope::enter(dir.path()); + let settings_path = dir.path().join(".claude").join("settings.json"); + fs::create_dir_all(settings_path.parent().unwrap()).unwrap(); + fs::write( + &settings_path, + serde_json::to_vec_pretty(&json!({ + "env": { + "ANTHROPIC_BASE_URL": "https://api.anthropic.com", + "OTHER": "kept" + } + })) + .unwrap(), + ) + .unwrap(); + + assert_eq!(claude_settings_path().unwrap(), settings_path); + assert_eq!( + claude_settings_base_url().as_deref(), + Some("https://api.anthropic.com") + ); + enable_claude_provider(DEFAULT_URL).unwrap(); + assert_eq!(claude_settings_base_url().as_deref(), Some(DEFAULT_URL)); + assert_eq!( + json_env_string(&read_json_object(&settings_path).unwrap(), "OTHER"), + Some("kept") + ); + restore_claude_provider(DEFAULT_URL).unwrap(); + assert_eq!( + claude_settings_base_url().as_deref(), + Some("https://api.anthropic.com") + ); + assert!(!backup_path(&settings_path).exists()); +} + +#[test] +fn claude_setup_snapshot_restores_settings_and_backup_exactly() { + let dir = tempdir().unwrap(); + let _home = HomeScope::enter(dir.path()); + let settings = claude_settings_path().unwrap(); + let backup = backup_path(&settings); + fs::create_dir_all(settings.parent().unwrap()).unwrap(); + let original_settings = br#"{"env":{"ANTHROPIC_BASE_URL":"https://original"}}"#; + let original_backup = br#"{"env":{"ANTHROPIC_BASE_URL":"https://backup"}}"#; + fs::write(&settings, original_settings).unwrap(); + fs::write(&backup, original_backup).unwrap(); + let snapshot = snapshot_claude_setup().unwrap(); + + fs::write(&settings, b"replacement-settings").unwrap(); + fs::remove_file(&backup).unwrap(); + restore_claude_setup(&snapshot).unwrap(); + + assert_eq!(fs::read(settings).unwrap(), original_settings); + assert_eq!(fs::read(backup).unwrap(), original_backup); + + fs::remove_file(claude_settings_path().unwrap()).unwrap(); + fs::remove_file(backup_path(&claude_settings_path().unwrap())).unwrap(); + let absent = snapshot_claude_setup().unwrap(); + fs::write(claude_settings_path().unwrap(), b"created").unwrap(); + fs::write(backup_path(&claude_settings_path().unwrap()), b"created").unwrap(); + restore_claude_setup(&absent).unwrap(); + assert!(!claude_settings_path().unwrap().exists()); + assert!(!backup_path(&claude_settings_path().unwrap()).exists()); +} + +#[test] +fn claude_provider_restore_noops_without_matching_backup_or_managed_value() { + let dir = tempdir().unwrap(); + let _home = HomeScope::enter(dir.path()); + let settings_path = dir.path().join(".claude").join("settings.json"); + fs::create_dir_all(settings_path.parent().unwrap()).unwrap(); + fs::write( + &settings_path, + serde_json::to_vec_pretty(&json!({ + "env": { "ANTHROPIC_BASE_URL": "https://custom.example" } + })) + .unwrap(), + ) + .unwrap(); + + restore_claude_provider(DEFAULT_URL).unwrap(); + assert_eq!( + claude_settings_base_url().as_deref(), + Some("https://custom.example") + ); + + backup_claude_settings(&settings_path, false).unwrap(); + restore_claude_provider(DEFAULT_URL).unwrap(); + assert_eq!( + claude_settings_base_url().as_deref(), + Some("https://custom.example") + ); + assert!(backup_path(&settings_path).exists()); +} + +#[test] +fn claude_provider_errors_for_non_object_env_and_restore_env_type_mismatch() { + let dir = tempdir().unwrap(); + let _home = HomeScope::enter(dir.path()); + let settings_path = dir.path().join(".claude").join("settings.json"); + fs::create_dir_all(settings_path.parent().unwrap()).unwrap(); + fs::write(&settings_path, r#"{"env": "bad"}"#).unwrap(); + + assert!( + enable_claude_provider(DEFAULT_URL) + .unwrap_err() + .contains("non-object env field") + ); + + let mut value = json!("bad"); + assert!( + remove_json_env_string(&mut value, "ANTHROPIC_BASE_URL") + .unwrap_err() + .contains("must be a JSON object") + ); + let mut value = json!({"env": "bad"}); + assert!( + remove_json_env_string(&mut value, "ANTHROPIC_BASE_URL") + .unwrap_err() + .contains("env field") + ); + let mut value = json!({"env": "bad"}); + assert!( + restore_json_env_value( + &mut value, + &json!({"env": {"ANTHROPIC_BASE_URL": DEFAULT_URL}}), + "ANTHROPIC_BASE_URL", + ) + .unwrap_err() + .contains("env field") + ); +} + +#[test] +fn claude_backup_bootstraps_missing_settings_and_replaces_stale_backup() { + let dir = tempdir().unwrap(); + let settings_path = dir.path().join(".claude").join("settings.json"); + let backup = backup_path(&settings_path); + backup_claude_settings(&settings_path, false).unwrap(); + assert_eq!( + serde_json::from_str::(&fs::read_to_string(&backup).unwrap()).unwrap(), + json!({"__nemo_relay_original_settings_absent": true}) + ); + fs::write(&settings_path, r#"{"env":{"ANTHROPIC_BASE_URL":"new"}}"#).unwrap(); + backup_claude_settings(&settings_path, false).unwrap(); + assert_eq!( + serde_json::from_str::(&fs::read_to_string(&backup).unwrap()).unwrap(), + json!({"__nemo_relay_original_settings_absent": true}) + ); + backup_claude_settings(&settings_path, true).unwrap(); + assert!( + fs::read_to_string(&backup) + .unwrap() + .contains("ANTHROPIC_BASE_URL") + ); +} + +#[test] +fn claude_restore_removes_settings_created_from_an_absent_original() { + let dir = tempdir().unwrap(); + let _home = HomeScope::enter(dir.path()); + let settings_path = dir.path().join(".claude/settings.json"); + + enable_claude_provider(DEFAULT_URL).unwrap(); + assert!(settings_path.exists()); + restore_claude_provider(DEFAULT_URL).unwrap(); + + assert!(!settings_path.exists()); +} + +#[test] +fn plugin_host_entrypoints_reject_unsupported_agents_and_report_json() { + let dir = tempdir().unwrap(); + let _home = HomeScope::enter(dir.path()); + let settings_path = dir.path().join(".claude").join("settings.json"); + fs::create_dir_all(settings_path.parent().unwrap()).unwrap(); + fs::write( + &settings_path, + serde_json::to_vec_pretty(&json!({ + "env": { "ANTHROPIC_BASE_URL": DEFAULT_URL } + })) + .unwrap(), + ) + .unwrap(); + let plugin_root = dir.path().join("plugin"); + let plugin_hooks = plugin_root.join("hooks").join("hooks.json"); + fs::create_dir_all(plugin_hooks.parent().unwrap()).unwrap(); + write_plugin_generation_for_hooks(&plugin_hooks); + fs::write( + &plugin_hooks, + serde_json::to_vec_pretty(&json!({ + "hooks": { + "SessionStart": [{ + "hooks": [{ + "type": "command", + "command": expected_plugin_command(), + "timeout": 30 + }] + }] + } + })) + .unwrap(), + ) + .unwrap(); + + let report = doctor_plugin_json(CodingAgent::ClaudeCode, DEFAULT_URL, &plugin_root).unwrap(); + assert_eq!(report["sidecar_health"], json!("not_running_mcp_start")); + assert_eq!(report["checks"]["claude_provider_routing"], json!(true)); + let codex_report = doctor_plugin_json(CodingAgent::Codex, DEFAULT_URL, &plugin_root).unwrap(); + assert_eq!( + codex_report["sidecar_health"], + json!("not_running_mcp_start") + ); + assert_eq!(codex_report["checks"]["codex_provider_alias"], json!(false)); + assert_eq!(codex_report["checks"]["codex_hooks"], json!(false)); + assert!( + doctor_plugin_json(CodingAgent::Hermes, DEFAULT_URL, &plugin_root) + .unwrap_err() + .contains("supports claude and codex") + ); + assert!( + doctor_plugin(CodingAgent::Hermes, DEFAULT_URL, &plugin_root) + .unwrap_err() + .contains("supports claude and codex") + ); + assert!( + doctor_plugin(CodingAgent::Codex, DEFAULT_URL, &plugin_root) + .unwrap_err() + .contains("codex plugin doctor checks failed") + ); +} + +fn event_contains_command(config: &Value, event: &str, command: &str) -> bool { + config + .get("hooks") + .and_then(Value::as_object) + .and_then(|hooks| hooks.get(event)) + .and_then(Value::as_array) + .is_some_and(|groups| { + groups.iter().any(|group| { + group + .get("hooks") + .and_then(Value::as_array) + .is_some_and(|hooks| { + hooks.iter().any(|hook| { + hook.get("command").and_then(Value::as_str) == Some(command) + }) + }) + }) + }) +} diff --git a/crates/cli/tests/coverage/agents/plugin_install_setup_tests.rs b/crates/cli/tests/coverage/agents/plugin_install_setup_tests.rs new file mode 100644 index 000000000..c88c3467b --- /dev/null +++ b/crates/cli/tests/coverage/agents/plugin_install_setup_tests.rs @@ -0,0 +1,168 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use serde_json::{Value, json}; +use std::cell::RefCell; + +use super::*; +use crate::agents::CodingAgent; + +struct DefaultsOnlyRunner; + +#[derive(Default)] +struct GenerationAwareRunner { + calls: RefCell>, +} + +impl PluginSetupRunner for DefaultsOnlyRunner { + fn setup( + &self, + _host_arg: &str, + _gateway_url: &str, + _plugin_root: &Path, + ) -> Result<(), String> { + Ok(()) + } + + fn uninstall( + &self, + _host_arg: &str, + _gateway_url: &str, + _plugin_root: &Path, + ) -> Result<(), String> { + Ok(()) + } + + fn doctor( + &self, + _host_arg: &str, + _gateway_url: &str, + _plugin_root: &Path, + ) -> Result<(), String> { + Ok(()) + } + + fn doctor_json( + &self, + _host_arg: &str, + _gateway_url: &str, + _plugin_root: &Path, + ) -> Result { + Ok(json!({})) + } +} + +impl PluginSetupRunner for GenerationAwareRunner { + fn setup( + &self, + _host_arg: &str, + _gateway_url: &str, + _plugin_root: &Path, + ) -> Result<(), String> { + panic!("generation-aware setup entry point was bypassed") + } + + fn setup_with_generation( + &self, + _host_arg: &str, + _gateway_url: &str, + _plugin_root: &Path, + generation_token: Option<&str>, + ) -> Result<(), String> { + self.calls + .borrow_mut() + .push(format!("setup:{}", generation_token.unwrap_or("missing"))); + Ok(()) + } + + fn uninstall( + &self, + _host_arg: &str, + _gateway_url: &str, + _plugin_root: &Path, + ) -> Result<(), String> { + Ok(()) + } + + fn doctor( + &self, + _host_arg: &str, + _gateway_url: &str, + _plugin_root: &Path, + ) -> Result<(), String> { + panic!("generation-aware doctor entry point was bypassed") + } + + fn doctor_with_generation( + &self, + _host_arg: &str, + _gateway_url: &str, + _plugin_root: &Path, + generation_token: Option<&str>, + ) -> Result<(), String> { + self.calls + .borrow_mut() + .push(format!("doctor:{}", generation_token.unwrap_or("missing"))); + Ok(()) + } + + fn doctor_json( + &self, + _host_arg: &str, + _gateway_url: &str, + _plugin_root: &Path, + ) -> Result { + Ok(json!({})) + } +} + +#[test] +fn setup_runner_defaults_are_explicit_no_ops() { + let runner = DefaultsOnlyRunner; + + assert!(runner.snapshot("codex").unwrap().is_none()); + runner.restore_snapshot(&PluginSetupSnapshot::Mock).unwrap(); + runner.refresh_gateway().unwrap(); +} + +#[test] +fn setup_and_doctor_receive_the_installer_verified_generation() { + let dir = tempfile::tempdir().unwrap(); + let layout = PluginLayout::new(CodingAgent::Codex, dir.path()); + let options = PluginInstallOptions { + install_dir: dir.path().to_owned(), + operation_lock_dir: dir.path().join("locks"), + force: false, + dry_run: false, + skip_doctor: false, + }; + let runner = GenerationAwareRunner::default(); + + run_plugin_setup_with_generation( + CodingAgent::Codex, + &layout, + &options, + &runner, + Some("generation-a"), + ) + .unwrap(); + run_plugin_doctor_with_generation( + CodingAgent::Codex, + &layout.plugin_root, + &options, + &runner, + Some("generation-a"), + ) + .unwrap(); + + assert_eq!( + *runner.calls.borrow(), + ["setup:generation-a", "doctor:generation-a"] + ); +} + +#[test] +fn setup_descriptions_reject_unknown_actions() { + let runner = HostPluginSetupRunner::new(CodingAgent::Codex); + assert!(std::panic::catch_unwind(|| runner.action_description("unknown")).is_err()); +} diff --git a/crates/cli/tests/coverage/agents/plugin_install_tests.rs b/crates/cli/tests/coverage/agents/plugin_install_tests.rs new file mode 100644 index 000000000..1f2ecc010 --- /dev/null +++ b/crates/cli/tests/coverage/agents/plugin_install_tests.rs @@ -0,0 +1,5337 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use std::cell::{Cell, RefCell}; +use std::collections::{HashMap, VecDeque}; +use std::ffi::OsString; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, Stdio}; +use std::sync::Mutex; +use std::thread; +use std::time::{Duration, Instant}; + +use serde_json::json; +use tempfile::tempdir; + +use super::host::{ + CommandOutput, HostRegistrationReport, format_command, host_registration_report, + require_host_cli, require_relay, run_capture_command, run_command, run_path_command, + validate_host_registration, validate_host_version, validate_relay_hook_forward, + validate_relay_mcp, +}; +use super::*; +use crate::agents::CodingAgent; +use crate::agents::strip_windows_verbatim_prefix; + +const OPERATION_LOCK_HELPER_DIR_ENV: &str = "NEMO_RELAY_TEST_OPERATION_LOCK_DIR"; +const OPERATION_LOCK_HELPER_GLOBAL_DIR_ENV: &str = "NEMO_RELAY_TEST_OPERATION_LOCK_GLOBAL_DIR"; +const GENERATION_LOCK_HELPER_PATH_ENV: &str = "NEMO_RELAY_TEST_GENERATION_LOCK_PATH"; +const LOCK_HELPER_READY_ENV: &str = "NEMO_RELAY_TEST_LOCK_READY"; +const LOCK_HELPER_RELEASE_ENV: &str = "NEMO_RELAY_TEST_LOCK_RELEASE"; +const TEST_GENERATION_TOKEN: &str = "test-generation"; + +fn force_snapshot_with_backups( + backup_marketplace_root: PathBuf, + backup_plugin_root: Option, +) -> ForceInstallSnapshot { + ForceInstallSnapshot { + state_bytes: None, + setup_snapshot: None, + original_marketplace_root: PathBuf::from("original-marketplace"), + original_plugin_root: PathBuf::from("separate-original-plugin"), + original_generation_fence: PathBuf::from("original-generation"), + plugin_registered: false, + marketplace_registered: false, + backup_marketplace_root, + backup_plugin_root, + marketplace_moved: true, + plugin_moved: true, + replacement_promoted: false, + generation_retirement: None, + } +} + +fn plugin_install_env_lock() -> &'static Mutex<()> { + &crate::test_support::ENV_TEST_LOCK +} + +#[test] +fn windows_verbatim_relay_paths_are_normalized_for_mcp_config() { + let normalize = |path: &str| { + let encoded = path.encode_utf16().collect::>(); + strip_windows_verbatim_prefix(&encoded) + .map(|normalized| String::from_utf16(&normalized).unwrap()) + }; + + assert_eq!( + normalize(r"\\?\C:\Program Files\NVIDIA\nemo-relay.exe"), + Some(r"C:\Program Files\NVIDIA\nemo-relay.exe".into()) + ); + assert_eq!( + normalize(r"\\?\UNC\server\share\nemo-relay.exe"), + Some(r"\\server\share\nemo-relay.exe".into()) + ); + assert_eq!(normalize(r"C:\nemo-relay.exe"), None); +} + +#[test] +fn readiness_worker_returns_a_report_and_handles_channel_disconnects() { + let dir = tempdir().unwrap(); + let readiness = collect_marketplace_readiness( + CodingAgent::Codex, + &options(dir.path()), + &MockRunner::default(), + ); + assert_eq!(readiness.host, "codex"); + assert!(!readiness.checks.is_empty()); + + let (sender, receiver) = std::sync::mpsc::sync_channel(1); + drop(sender); + let readiness = crate::agents::receive_integration_readiness_for_test( + CodingAgent::ClaudeCode, + dir.path().join("claude-state.json"), + receiver, + dir.path(), + Duration::from_secs(1), + ); + assert!(!readiness.ok()); + assert!( + readiness.checks[0] + .details + .contains("collector stopped unexpectedly") + ); + + let (sender, receiver) = std::sync::mpsc::sync_channel(1); + drop(sender); + let hermes = crate::agents::receive_integration_readiness_for_test( + CodingAgent::Hermes, + dir.path().join("config.yaml"), + receiver, + dir.path(), + Duration::from_secs(1), + ); + assert!(hermes.marketplace.is_none()); + assert!(hermes.plugin.is_none()); + assert!(!hermes.ok()); +} + +#[test] +fn committed_force_snapshot_removes_all_backup_trees_best_effort() { + let dir = tempdir().unwrap(); + let marketplace = dir.path().join("marketplace-backup"); + let plugin = dir.path().join("plugin-backup"); + std::fs::create_dir_all(&marketplace).unwrap(); + std::fs::create_dir_all(&plugin).unwrap(); + + force_snapshot_with_backups(marketplace.clone(), Some(plugin.clone())) + .commit(&dir.path().join("replacement.lock")); + + assert!(!marketplace.exists()); + assert!(!plugin.exists()); + + let missing_marketplace = dir.path().join("missing-marketplace"); + let missing_plugin = dir.path().join("missing-plugin"); + force_snapshot_with_backups(missing_marketplace, Some(missing_plugin)) + .commit(&dir.path().join("replacement.lock")); + + let marketplace_file = dir.path().join("marketplace-file"); + let plugin_file = dir.path().join("plugin-file"); + std::fs::write(&marketplace_file, "file").unwrap(); + std::fs::write(&plugin_file, "file").unwrap(); + force_snapshot_with_backups(marketplace_file.clone(), Some(plugin_file.clone())) + .commit(&dir.path().join("replacement.lock")); + assert!(marketplace_file.exists()); + assert!(plugin_file.exists()); +} + +#[test] +fn dry_run_cleanup_and_rollback_cover_absent_install_state() { + let dir = tempdir().unwrap(); + let mut dry_run = options(dir.path()); + dry_run.dry_run = true; + let layout = PluginLayout::new(CodingAgent::ClaudeCode, dir.path()); + let runner = MockRunner::default(); + let setup_runner = MockSetupRunner::default(); + + force_cleanup_existing_install( + CodingAgent::ClaudeCode, + &layout, + &dry_run, + &runner, + &setup_runner, + ) + .unwrap(); + rollback_install( + CodingAgent::ClaudeCode, + &layout, + HostRegistrationProgress::default(), + false, + &dry_run, + &runner, + &setup_runner, + ) + .unwrap(); +} + +#[test] +fn staged_marketplace_promotion_reports_the_source_and_target() { + let dir = tempdir().unwrap(); + let staged_parent = dir.path().join("stage"); + let target_parent = dir.path().join("target"); + let staged = StagedPluginMarketplace { + layout: PluginLayout::new(CodingAgent::Codex, &staged_parent), + parent: staged_parent, + generation_lock_created: false, + }; + let target = PluginLayout::new(CodingAgent::Codex, &target_parent); + + let error = staged.promote(&target).unwrap_err(); + + assert!(error.contains("failed to promote staged marketplace")); + assert!( + error.contains(&staged.layout.marketplace_root.display().to_string()), + "{error}" + ); + assert!( + error.contains(&target.marketplace_root.display().to_string()), + "{error}" + ); +} + +#[test] +fn replacement_generation_guard_removes_an_owned_lock_after_marker_removal() { + let dir = tempdir().unwrap(); + let marker = dir.path().join("generation-marker"); + let lock = dir.path().join("generation.lock"); + crate::installation::generation::write_new_generation_with_token_at(&marker, &lock).unwrap(); + let guard = + acquire_replacement_generation_lock(CodingAgent::Codex, &marker, &lock, true).unwrap(); + + std::fs::remove_file(marker).unwrap(); + drop(guard); + + assert!(!lock.exists()); +} + +#[test] +fn replacement_generation_guard_retains_its_lock_when_marker_state_is_uncertain() { + let dir = tempdir().unwrap(); + let marker = dir.path().join("generation-marker"); + let lock = dir.path().join("generation.lock"); + crate::installation::generation::write_new_generation_with_token_at(&marker, &lock).unwrap(); + let guard = + acquire_replacement_generation_lock(CodingAgent::Codex, &marker, &lock, true).unwrap(); + + std::fs::remove_file(&marker).unwrap(); + std::fs::create_dir(&marker).unwrap(); + drop(guard); + + assert!(lock.exists()); +} + +#[test] +fn codex_plugin_requires_version_with_complete_hook_support() { + let dir = tempdir().unwrap(); + let normal = options(dir.path()); + let supported = MockRunner::default() + .with_executable("codex", "/bin/codex") + .with_capture_output("/bin/codex --version", "codex-cli 0.143.0\n"); + validate_host_version(CodingAgent::Codex, &normal, &supported).unwrap(); + + let old = MockRunner::default() + .with_executable("codex", "/bin/codex") + .with_capture_output("/bin/codex --version", "codex-cli 0.142.9\n"); + assert!( + validate_host_version(CodingAgent::Codex, &normal, &old) + .unwrap_err() + .contains("requires codex-cli 0.143.0") + ); + + let invalid = MockRunner::default() + .with_executable("codex", "/bin/codex") + .with_capture_output("/bin/codex --version", "codex nightly\n"); + assert!( + validate_host_version(CodingAgent::Codex, &normal, &invalid) + .unwrap_err() + .contains("could not parse") + ); + + let prerelease = MockRunner::default() + .with_executable("codex", "/bin/codex") + .with_capture_output("/bin/codex --version", "codex-cli 0.143.0-alpha.1\n"); + assert!( + validate_host_version(CodingAgent::Codex, &normal, &prerelease) + .unwrap_err() + .contains("codex-cli 0.143.0-alpha.1 is unsupported") + ); + + for malformed in [ + "codex-cli 0.143\n", + "codex-cli v0.143.0\n", + "warning 1.2.3\ncodex-cli 0.143.0\n", + ] { + let runner = MockRunner::default() + .with_executable("codex", "/bin/codex") + .with_capture_output("/bin/codex --version", malformed); + assert!( + validate_host_version(CodingAgent::Codex, &normal, &runner) + .unwrap_err() + .contains("could not parse"), + "unexpectedly parsed {malformed:?}" + ); + } +} + +#[test] +fn claude_plugin_requires_version_with_always_load_support() { + let dir = tempdir().unwrap(); + let normal = options(dir.path()); + let supported = MockRunner::default() + .with_executable("claude", "/bin/claude") + .with_capture_output("/bin/claude --version", "2.1.121 (Claude Code)\n"); + validate_host_version(CodingAgent::ClaudeCode, &normal, &supported).unwrap(); + + for unsupported in ["2.1.120 (Claude Code)\n", "2.1.121-beta (Claude Code)\n"] { + let runner = MockRunner::default() + .with_executable("claude", "/bin/claude") + .with_capture_output("/bin/claude --version", unsupported); + assert!( + validate_host_version(CodingAgent::ClaudeCode, &normal, &runner) + .unwrap_err() + .contains("requires Claude Code 2.1.121"), + "unexpectedly accepted {unsupported:?}" + ); + } + + for malformed in ["Claude Code 2.1.121\n", "2.1\n", "warning\n2.1.121\n"] { + let runner = MockRunner::default() + .with_executable("claude", "/bin/claude") + .with_capture_output("/bin/claude --version", malformed); + assert!( + validate_host_version(CodingAgent::ClaudeCode, &normal, &runner) + .unwrap_err() + .contains("could not parse"), + "unexpectedly parsed {malformed:?}" + ); + } +} + +struct HomeScope<'a> { + _guard: std::sync::MutexGuard<'a, ()>, + prev_home: Option, + prev_userprofile: Option, + prev_codex_home: Option, + prev_hermes_home: Option, +} + +impl<'a> HomeScope<'a> { + fn enter(path: &Path) -> Self { + let guard = plugin_install_env_lock() + .lock() + .unwrap_or_else(|error| error.into_inner()); + let prev_home = std::env::var_os("HOME"); + let prev_userprofile = std::env::var_os("USERPROFILE"); + let prev_codex_home = std::env::var_os("CODEX_HOME"); + let prev_hermes_home = std::env::var_os("HERMES_HOME"); + // SAFETY: This test holds a process-wide mutex for the lifetime of the env override. + unsafe { + std::env::set_var("HOME", path); + std::env::remove_var("USERPROFILE"); + std::env::remove_var("CODEX_HOME"); + std::env::remove_var("HERMES_HOME"); + } + Self { + _guard: guard, + prev_home, + prev_userprofile, + prev_codex_home, + prev_hermes_home, + } + } + + fn without_home() -> Self { + let guard = plugin_install_env_lock() + .lock() + .unwrap_or_else(|error| error.into_inner()); + let prev_home = std::env::var_os("HOME"); + let prev_userprofile = std::env::var_os("USERPROFILE"); + let prev_codex_home = std::env::var_os("CODEX_HOME"); + let prev_hermes_home = std::env::var_os("HERMES_HOME"); + // SAFETY: This test holds a process-wide mutex for the lifetime of the env override. + unsafe { + std::env::remove_var("HOME"); + std::env::remove_var("USERPROFILE"); + std::env::remove_var("CODEX_HOME"); + std::env::remove_var("HERMES_HOME"); + } + Self { + _guard: guard, + prev_home, + prev_userprofile, + prev_codex_home, + prev_hermes_home, + } + } +} + +impl Drop for HomeScope<'_> { + fn drop(&mut self) { + // SAFETY: This restores the process environment while the mutex is still held. + unsafe { + match self.prev_home.take() { + Some(value) => std::env::set_var("HOME", value), + None => std::env::remove_var("HOME"), + } + match self.prev_userprofile.take() { + Some(value) => std::env::set_var("USERPROFILE", value), + None => std::env::remove_var("USERPROFILE"), + } + match self.prev_codex_home.take() { + Some(value) => std::env::set_var("CODEX_HOME", value), + None => std::env::remove_var("CODEX_HOME"), + } + match self.prev_hermes_home.take() { + Some(value) => std::env::set_var("HERMES_HOME", value), + None => std::env::remove_var("HERMES_HOME"), + } + } + } +} + +#[test] +fn plugin_operation_lock_directory_requires_a_user_home() { + let _home = HomeScope::without_home(); + + let error = default_operation_lock_dir().unwrap_err(); + + assert!(error.contains("set HOME or USERPROFILE"), "{error}"); +} + +struct PathScope<'a> { + _guard: std::sync::MutexGuard<'a, ()>, + previous: Option, + previous_home: Option, + previous_codex_home: Option, + previous_hermes_home: Option, +} + +impl<'a> PathScope<'a> { + fn set_isolated(path: &Path, home: &Path) -> Self { + let guard = plugin_install_env_lock() + .lock() + .unwrap_or_else(|error| error.into_inner()); + let previous = std::env::var_os("PATH"); + let previous_home = std::env::var_os("HOME"); + let previous_codex_home = std::env::var_os("CODEX_HOME"); + let previous_hermes_home = std::env::var_os("HERMES_HOME"); + // SAFETY: This test holds the process-wide environment mutex for the override lifetime. + unsafe { + std::env::set_var("PATH", path); + std::env::set_var("HOME", home); + std::env::remove_var("CODEX_HOME"); + std::env::remove_var("HERMES_HOME"); + } + Self { + _guard: guard, + previous, + previous_home, + previous_codex_home, + previous_hermes_home, + } + } +} + +impl Drop for PathScope<'_> { + fn drop(&mut self) { + // SAFETY: This restores PATH while the process-wide environment mutex is still held. + unsafe { + match self.previous.take() { + Some(value) => std::env::set_var("PATH", value), + None => std::env::remove_var("PATH"), + } + match self.previous_home.take() { + Some(value) => std::env::set_var("HOME", value), + None => std::env::remove_var("HOME"), + } + match self.previous_codex_home.take() { + Some(value) => std::env::set_var("CODEX_HOME", value), + None => std::env::remove_var("CODEX_HOME"), + } + match self.previous_hermes_home.take() { + Some(value) => std::env::set_var("HERMES_HOME", value), + None => std::env::remove_var("HERMES_HOME"), + } + } + } +} + +#[derive(Default)] +struct MockRunner { + current_executable: Option, + executables: HashMap, + commands: RefCell>, + quiet_commands: RefCell>, + capture_commands: RefCell>, + capture_outputs: HashMap, + capture_output_sequences: RefCell>>, + failing_suffix: Option, + failing_suffixes: Vec, + failing_quiet_suffix: Option, +} + +impl MockRunner { + fn with_current_executable(mut self, path: &str) -> Self { + self.current_executable = Some(PathBuf::from(path)); + self + } + + fn with_executable(mut self, name: &str, path: &str) -> Self { + self.executables.insert(name.into(), PathBuf::from(path)); + self + } + + fn with_capture_output(mut self, command: &str, stdout: impl Into) -> Self { + self.capture_outputs + .insert(command.into(), CommandOutput::success(stdout.into())); + self + } + + fn with_capture_status( + mut self, + command: &str, + status: i32, + stdout: impl Into, + stderr: impl Into, + ) -> Self { + self.capture_outputs.insert( + command.into(), + CommandOutput { + status, + stdout: stdout.into(), + stderr: stderr.into(), + }, + ); + self + } + + fn with_codex_registration(mut self, plugin: bool, marketplace: bool) -> Self { + let plugin_output = if plugin { + "nemo-relay-plugin@nemo-relay-local installed, enabled\n" + } else { + "" + }; + let marketplace_output = if marketplace { + "nemo-relay-local /tmp/nemo-relay-local\n" + } else { + "" + }; + self.capture_outputs.insert( + "/bin/codex plugin list".into(), + CommandOutput::success(plugin_output.into()), + ); + self.capture_outputs.insert( + "/bin/codex plugin marketplace list".into(), + CommandOutput::success(marketplace_output.into()), + ); + self + } + + fn with_claude_registration(mut self, plugin: bool, marketplace: bool) -> Self { + let plugins = if plugin { + json!([{ "id": "nemo-relay-plugin@nemo-relay-local" }]) + } else { + json!([]) + }; + let marketplaces = if marketplace { + json!([{ "name": "nemo-relay-local" }]) + } else { + json!([]) + }; + self.capture_outputs.insert( + "/bin/claude plugin list --json".into(), + CommandOutput::success(plugins.to_string()), + ); + self.capture_outputs.insert( + "/bin/claude plugin marketplace list --json".into(), + CommandOutput::success(marketplaces.to_string()), + ); + self + } + + fn with_codex_registration_sequence(mut self, states: &[(bool, bool)]) -> Self { + let plugin_outputs = states + .iter() + .map(|(plugin, _)| { + CommandOutput::success( + plugin + .then_some("nemo-relay-plugin@nemo-relay-local installed, enabled\n") + .unwrap_or_default() + .into(), + ) + }) + .collect(); + let marketplace_outputs = states + .iter() + .map(|(_, marketplace)| { + CommandOutput::success( + marketplace + .then_some("nemo-relay-local /tmp/nemo-relay-local\n") + .unwrap_or_default() + .into(), + ) + }) + .collect(); + self.capture_output_sequences + .get_mut() + .insert("/bin/codex plugin list".into(), plugin_outputs); + self.capture_output_sequences.get_mut().insert( + "/bin/codex plugin marketplace list".into(), + marketplace_outputs, + ); + self + } + + fn commands(&self) -> Vec { + self.commands.borrow().clone() + } + + fn quiet_commands(&self) -> Vec { + self.quiet_commands.borrow().clone() + } + + fn capture_commands(&self) -> Vec { + self.capture_commands.borrow().clone() + } +} + +impl CommandRunner for MockRunner { + fn current_executable(&self) -> Result { + self.current_executable + .clone() + .or_else(|| self.executables.get(RELAY_COMMAND).cloned()) + .ok_or_else(|| "failed to resolve current nemo-relay executable".into()) + } + + fn resolve_executable(&self, command: &str) -> Result, String> { + Ok(self.executables.get(command).cloned()) + } + + fn run(&self, program: &Path, args: &[String]) -> Result { + let rendered = format!( + "{} {}", + program.display(), + args.iter() + .map(String::as_str) + .collect::>() + .join(" ") + ); + self.commands.borrow_mut().push(rendered.clone()); + Ok( + if command_matches_suffix(&rendered, self.failing_suffix.as_deref()) + || self + .failing_suffixes + .iter() + .any(|suffix| rendered.ends_with(suffix)) + { + 1 + } else { + 0 + }, + ) + } + + fn run_quiet(&self, program: &Path, args: &[String]) -> Result { + let rendered = format!( + "{} {}", + program.display(), + args.iter() + .map(String::as_str) + .collect::>() + .join(" ") + ); + self.quiet_commands.borrow_mut().push(rendered.clone()); + Ok( + if command_matches_suffix(&rendered, self.failing_quiet_suffix.as_deref()) { + 1 + } else { + 0 + }, + ) + } + + fn run_capture(&self, program: &Path, args: &[String]) -> Result { + let rendered = format!( + "{} {}", + program.display(), + args.iter() + .map(String::as_str) + .collect::>() + .join(" ") + ); + self.capture_commands.borrow_mut().push(rendered.clone()); + if let Some(output) = self + .capture_output_sequences + .borrow_mut() + .get_mut(&rendered) + .and_then(VecDeque::pop_front) + { + return Ok(output); + } + Ok(self + .capture_outputs + .get(&rendered) + .cloned() + .unwrap_or_else(|| { + if rendered.ends_with("codex --version") { + CommandOutput::success("codex-cli 0.143.0\n".into()) + } else if rendered.ends_with("claude --version") { + CommandOutput::success("2.1.121 (Claude Code)\n".into()) + } else if rendered.ends_with("claude plugin list --json") + || rendered.ends_with("claude plugin marketplace list --json") + { + CommandOutput::success("[]\n".into()) + } else { + CommandOutput::success(String::new()) + } + })) + } +} + +fn command_matches_suffix(command: &str, suffix: Option<&str>) -> bool { + suffix.is_some_and(|suffix| command.ends_with(suffix)) +} + +#[derive(Default)] +struct MockSetupRunner { + calls: RefCell>, + doctor_roots: RefCell>, + failing_call: Option, +} + +struct BlockingRefreshFailure { + entered: std::sync::mpsc::Sender<()>, + continue_refresh: std::sync::mpsc::Receiver<()>, +} + +struct FailStateWriteAfterRefresh { + state_path: PathBuf, + injected: Cell, +} + +impl PluginSetupRunner for FailStateWriteAfterRefresh { + fn snapshot(&self, _host_arg: &str) -> Result, String> { + Ok(Some(PluginSetupSnapshot::Mock)) + } + + fn restore_snapshot(&self, _snapshot: &PluginSetupSnapshot) -> Result<(), String> { + Ok(()) + } + + fn refresh_gateway(&self) -> Result<(), String> { + if !self.injected.replace(true) { + crate::filesystem::fail_next_atomic_write(&self.state_path); + } + Ok(()) + } + + fn setup( + &self, + _host_arg: &str, + _gateway_url: &str, + _plugin_root: &Path, + ) -> Result<(), String> { + Ok(()) + } + + fn uninstall( + &self, + _host_arg: &str, + _gateway_url: &str, + _plugin_root: &Path, + ) -> Result<(), String> { + Ok(()) + } + + fn doctor( + &self, + _host_arg: &str, + _gateway_url: &str, + _plugin_root: &Path, + ) -> Result<(), String> { + Ok(()) + } + + fn doctor_json( + &self, + _host_arg: &str, + _gateway_url: &str, + _plugin_root: &Path, + ) -> Result { + Ok(json!({"ok": true, "checks": {}})) + } +} + +impl PluginSetupRunner for BlockingRefreshFailure { + fn snapshot(&self, _host_arg: &str) -> Result, String> { + Ok(Some(PluginSetupSnapshot::Mock)) + } + + fn restore_snapshot(&self, _snapshot: &PluginSetupSnapshot) -> Result<(), String> { + Ok(()) + } + + fn refresh_gateway(&self) -> Result<(), String> { + self.entered.send(()).unwrap(); + self.continue_refresh.recv().unwrap(); + Err("refresh gateway failed".into()) + } + + fn setup( + &self, + _host_arg: &str, + _gateway_url: &str, + _plugin_root: &Path, + ) -> Result<(), String> { + Ok(()) + } + + fn uninstall( + &self, + _host_arg: &str, + _gateway_url: &str, + _plugin_root: &Path, + ) -> Result<(), String> { + Ok(()) + } + + fn doctor( + &self, + _host_arg: &str, + _gateway_url: &str, + _plugin_root: &Path, + ) -> Result<(), String> { + Ok(()) + } + + fn doctor_json( + &self, + _host_arg: &str, + _gateway_url: &str, + _plugin_root: &Path, + ) -> Result { + Ok(json!({"ok": true, "checks": {}})) + } +} + +impl MockSetupRunner { + fn calls(&self) -> Vec { + self.calls.borrow().clone() + } + + fn doctor_roots(&self) -> Vec { + self.doctor_roots.borrow().clone() + } +} + +impl PluginSetupRunner for MockSetupRunner { + fn snapshot(&self, host_arg: &str) -> Result, String> { + self.record(format!("snapshot {host_arg}"))?; + Ok(Some(PluginSetupSnapshot::Mock)) + } + + fn restore_snapshot(&self, _snapshot: &PluginSetupSnapshot) -> Result<(), String> { + self.record("restore snapshot".into()) + } + + fn refresh_gateway(&self) -> Result<(), String> { + self.record("refresh gateway".into()) + } + + fn setup(&self, host_arg: &str, gateway_url: &str, _plugin_root: &Path) -> Result<(), String> { + self.record(format!("setup {host_arg} {gateway_url}")) + } + + fn uninstall( + &self, + host_arg: &str, + gateway_url: &str, + _plugin_root: &Path, + ) -> Result<(), String> { + self.record(format!("uninstall {host_arg} {gateway_url}")) + } + + fn doctor(&self, host_arg: &str, gateway_url: &str, _plugin_root: &Path) -> Result<(), String> { + self.record(format!("doctor {host_arg} {gateway_url}")) + } + + fn doctor_json( + &self, + host_arg: &str, + gateway_url: &str, + plugin_root: &Path, + ) -> Result { + self.doctor_roots + .borrow_mut() + .push(plugin_root.to_path_buf()); + self.record(format!("doctor-json {host_arg} {gateway_url}"))?; + Ok(json!({ + "ok": true, + "checks": {} + })) + } +} + +impl MockSetupRunner { + fn record(&self, call: String) -> Result<(), String> { + self.calls.borrow_mut().push(call.clone()); + if self.failing_call.as_deref() == Some(call.as_str()) { + Err(format!("{call} failed")) + } else { + Ok(()) + } + } +} + +fn options(dir: &Path) -> PluginInstallOptions { + PluginInstallOptions { + install_dir: dir.to_path_buf(), + operation_lock_dir: dir.join("operation-locks"), + force: false, + dry_run: false, + skip_doctor: true, + } +} + +fn relay_validation_command() -> String { + "/bin/nemo-relay hook-forward --help".into() +} + +fn relay_mcp_validation_command() -> String { + "/bin/nemo-relay mcp --help".into() +} + +fn write_installed_state(host: CodingAgent, dir: &Path) { + let layout = PluginLayout::new(host, dir); + write_plugin_marketplace(host, &layout, Path::new("/bin/nemo-relay"), &options(dir)).unwrap(); + write_state(&layout, &options(dir)).unwrap(); + mark_plugin_setup_installed(host, &layout, &options(dir)).unwrap(); +} + +#[cfg(windows)] +fn replace_generation_with_legacy_marker(layout: &PluginLayout) -> (String, PathBuf) { + let token = { + let generation = InstallGeneration::capture(layout.generation_fence.clone()).unwrap(); + generation.token().to_owned() + }; + std::fs::remove_file(&layout.generation_lock).unwrap(); + crate::installation::generation::write_legacy_generation(&layout.generation_fence, &token) + .unwrap(); + let mut lock_path = layout.generation_fence.as_os_str().to_os_string(); + lock_path.push(".lock"); + (token, PathBuf::from(lock_path)) +} + +fn write_relocated_codex_install(selected_dir: &Path, relocated_dir: &Path) -> PluginLayout { + let relocated = PluginLayout::new(CodingAgent::Codex, relocated_dir); + write_plugin_marketplace( + CodingAgent::Codex, + &relocated, + Path::new("/bin/nemo-relay"), + &options(selected_dir), + ) + .unwrap(); + write_state_for_host( + CodingAgent::Codex, + &PluginState { + marketplace_root: relocated.marketplace_root.clone(), + plugin_root: relocated.plugin_root.clone(), + host_plugin_removed: false, + host_marketplace_removed: false, + plugin_setup_installed: true, + }, + selected_dir, + &options(selected_dir), + ) + .unwrap(); + relocated +} + +fn assert_no_install_stage(dir: &Path) { + assert!(std::fs::read_dir(dir).unwrap().all(|entry| { + !entry + .unwrap() + .file_name() + .to_string_lossy() + .contains("install-stage") + })); +} + +fn assert_no_force_replacement_residue(dir: &Path) { + assert!(std::fs::read_dir(dir).unwrap().all(|entry| { + let name = entry.unwrap().file_name(); + let name = name.to_string_lossy(); + !name.contains("install-stage") + && !name.contains("marketplace-backup") + && !name.contains("plugin-backup") + })); +} + +fn assert_actionable_generation_error(error: &str, cause: &str) { + assert!(error.contains(cause), "{error}"); + assert!(error.contains("close all Codex clients"), "{error}"); + assert!( + error.contains("codex plugin remove nemo-relay-plugin@nemo-relay-local"), + "{error}" + ); + assert!( + error.contains("codex plugin marketplace remove nemo-relay-local"), + "{error}" + ); + assert!(error.contains("stale marketplace and state"), "{error}"); + assert!( + error.contains("nemo-relay install codex --force"), + "{error}" + ); +} + +fn corrupt_generation_fence(path: &Path, corruption: &str) { + match corruption { + "empty" => std::fs::write(path, b"").unwrap(), + "oversized" => std::fs::write(path, vec![b'x'; 129]).unwrap(), + "unreadable" => { + std::fs::remove_file(path).unwrap(); + std::fs::create_dir(path).unwrap(); + } + _ => unreachable!(), + } +} + +struct CrossProcessLockHolder { + child: Option, + release: PathBuf, +} + +impl CrossProcessLockHolder { + fn spawn( + env_name: &str, + target: &Path, + global_lock_dir: Option<&Path>, + synchronization_dir: &Path, + ) -> Self { + let ready = synchronization_dir.join("ready"); + let release = synchronization_dir.join("release"); + let mut command = Command::new(std::env::current_exe().unwrap()); + command + .args([ + "--exact", + "installation::marketplace::tests::cross_process_lock_holder", + "--nocapture", + ]) + .env(env_name, target) + .env(LOCK_HELPER_READY_ENV, &ready) + .env(LOCK_HELPER_RELEASE_ENV, &release) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + if let Some(global_lock_dir) = global_lock_dir { + command.env(OPERATION_LOCK_HELPER_GLOBAL_DIR_ENV, global_lock_dir); + } + let mut child = command.spawn().unwrap(); + let deadline = Instant::now() + Duration::from_secs(5); + loop { + if ready.exists() { + break; + } + if let Some(status) = child.try_wait().unwrap() { + panic!("cross-process lock holder exited before acquiring its lock: {status}"); + } + assert!( + Instant::now() < deadline, + "cross-process lock holder did not become ready" + ); + thread::sleep(Duration::from_millis(10)); + } + Self { + child: Some(child), + release, + } + } + + fn release(mut self) { + self.finish(); + } + + fn finish(&mut self) { + let Some(mut child) = self.child.take() else { + return; + }; + std::fs::write(&self.release, b"release").unwrap(); + let deadline = Instant::now() + Duration::from_secs(5); + loop { + if child.try_wait().unwrap().is_some() { + return; + } + if Instant::now() >= deadline { + let _ = child.kill(); + let _ = child.wait(); + panic!("cross-process lock holder did not exit after release"); + } + thread::sleep(Duration::from_millis(10)); + } + } +} + +impl Drop for CrossProcessLockHolder { + fn drop(&mut self) { + if self.child.is_some() { + self.finish(); + } + } +} + +#[test] +fn cross_process_lock_holder() { + let ready = match std::env::var_os(LOCK_HELPER_READY_ENV) { + Some(path) => PathBuf::from(path), + None => return, + }; + let release = PathBuf::from(std::env::var_os(LOCK_HELPER_RELEASE_ENV).unwrap()); + let _operation_lock; + let _generation_retirement; + if let Some(path) = std::env::var_os(OPERATION_LOCK_HELPER_DIR_ENV) { + let global_lock_dir = + PathBuf::from(std::env::var_os(OPERATION_LOCK_HELPER_GLOBAL_DIR_ENV).unwrap()); + _operation_lock = Some( + PluginOperationLock::acquire( + CodingAgent::Codex.install_arg(), + &global_lock_dir, + Path::new(&path), + Duration::from_secs(5), + ) + .unwrap(), + ); + _generation_retirement = None; + } else if let Some(path) = std::env::var_os(GENERATION_LOCK_HELPER_PATH_ENV) { + _operation_lock = None; + _generation_retirement = GenerationRetirement::acquire(Path::new(&path)).unwrap(); + assert!(_generation_retirement.is_some()); + } else { + return; + } + std::fs::write(ready, b"ready").unwrap(); + let deadline = Instant::now() + Duration::from_secs(10); + while !release.exists() { + assert!(Instant::now() < deadline, "lock holder release timed out"); + thread::sleep(Duration::from_millis(10)); + } +} + +#[test] +fn concurrent_install_install_times_out_without_mutating() { + let dir = tempdir().unwrap(); + let synchronization = tempdir().unwrap(); + let install_options = options(dir.path()); + let holder = CrossProcessLockHolder::spawn( + OPERATION_LOCK_HELPER_DIR_ENV, + dir.path(), + Some(&install_options.operation_lock_dir), + synchronization.path(), + ); + let runner = MockRunner::default(); + let setup_runner = MockSetupRunner::default(); + + let error = install_host_with_operation_timeout( + CodingAgent::Codex, + &install_options, + &runner, + &setup_runner, + Duration::from_millis(75), + ) + .expect_err("contended install unexpectedly succeeded"); + + assert!(error.contains("another codex plugin install or uninstall")); + assert!(runner.commands().is_empty()); + assert!(setup_runner.calls().is_empty()); + holder.release(); +} + +#[test] +fn concurrent_install_uninstall_times_out_without_mutating() { + let dir = tempdir().unwrap(); + let synchronization = tempdir().unwrap(); + let install_options = options(dir.path()); + let holder = CrossProcessLockHolder::spawn( + OPERATION_LOCK_HELPER_DIR_ENV, + dir.path(), + Some(&install_options.operation_lock_dir), + synchronization.path(), + ); + let runner = MockRunner::default(); + let setup_runner = MockSetupRunner::default(); + + let error = uninstall_host_with_operation_timeout( + CodingAgent::Codex, + &install_options, + &runner, + &setup_runner, + Duration::from_millis(75), + ) + .expect_err("contended uninstall unexpectedly succeeded"); + + assert!(error.contains("another codex plugin install or uninstall")); + assert!(runner.commands().is_empty()); + assert!(setup_runner.calls().is_empty()); + holder.release(); +} + +#[test] +fn concurrent_different_install_roots_share_the_global_host_lock() { + let root = tempdir().unwrap(); + let first_install_dir = root.path().join("first-install"); + let second_install_dir = root.path().join("second-install"); + let global_lock_dir = root.path().join("global-operation-locks"); + let synchronization = tempdir().unwrap(); + let holder = CrossProcessLockHolder::spawn( + OPERATION_LOCK_HELPER_DIR_ENV, + &first_install_dir, + Some(&global_lock_dir), + synchronization.path(), + ); + let runner = MockRunner::default(); + let setup_runner = MockSetupRunner::default(); + let mut second_options = options(&second_install_dir); + second_options.operation_lock_dir = global_lock_dir; + + let install_error = install_host_with_operation_timeout( + CodingAgent::Codex, + &second_options, + &runner, + &setup_runner, + Duration::from_millis(75), + ) + .unwrap_err(); + + assert!(install_error.contains("global lock"), "{install_error}"); + assert!(runner.commands().is_empty()); + assert!(setup_runner.calls().is_empty()); + holder.release(); +} + +#[test] +fn plugin_operation_lock_acquires_an_aliased_global_and_install_root_once() { + let root = tempdir().unwrap(); + let install_dir = root.path().join("new").join(".."); + let synchronization = tempdir().unwrap(); + let holder = CrossProcessLockHolder::spawn( + OPERATION_LOCK_HELPER_DIR_ENV, + &install_dir, + Some(root.path()), + synchronization.path(), + ); + + let Err(error) = PluginOperationLock::acquire( + "codex", + root.path(), + &root.path().join("."), + Duration::from_millis(75), + ) else { + panic!("aliased lock acquisition unexpectedly succeeded"); + }; + + assert!(error.contains("global lock"), "{error}"); + holder.release(); +} + +#[test] +fn generation_retirement_lock_contention_is_bounded_across_processes() { + let dir = tempdir().unwrap(); + let synchronization = tempdir().unwrap(); + let generation = dir.path().join(GENERATION_FILE_NAME); + crate::installation::generation::write_new_generation(&generation).unwrap(); + let holder = CrossProcessLockHolder::spawn( + GENERATION_LOCK_HELPER_PATH_ENV, + &generation, + None, + synchronization.path(), + ); + + let error = GenerationRetirement::acquire_with_timeout(&generation, Duration::from_millis(75)) + .err() + .expect("contended generation retirement must time out"); + + assert!(error.contains("timed out waiting for MCP install generation lock")); + holder.release(); +} + +#[test] +fn default_install_dir_follows_platform_conventions() { + assert_eq!( + default_install_dir_for("macos", Some("/Users/example".into()), None, None, None), + PathBuf::from("/Users/example/Library/Application Support/nemo-relay/plugins") + ); + assert_eq!( + default_install_dir_for("linux", Some("/home/example".into()), None, None, None), + PathBuf::from("/home/example/.local/share/nemo-relay/plugins") + ); + assert_eq!( + default_install_dir_for( + "linux", + Some("/home/example".into()), + None, + None, + Some("/data".into()) + ), + PathBuf::from("/data/nemo-relay/plugins") + ); + assert_eq!( + default_install_dir_for( + "windows", + None, + Some(r"C:\Users\example".into()), + Some(r"C:\Users\example\AppData\Local".into()), + None + ), + PathBuf::from(r"C:\Users\example\AppData\Local") + .join("nemo-relay") + .join("plugins") + ); +} + +#[test] +fn plugin_manifests_and_hooks_use_path_based_relay_command() { + assert_eq!( + marketplace_manifest(CodingAgent::Codex)["name"], + json!(MARKETPLACE_NAME) + ); + assert_eq!( + marketplace_manifest(CodingAgent::ClaudeCode)["plugins"][0]["source"], + json!("./plugins/nemo-relay-plugin") + ); + assert_eq!( + plugin_manifest(CodingAgent::Codex)["name"], + json!(PLUGIN_NAME) + ); + assert_eq!( + plugin_manifest(CodingAgent::Codex)["mcpServers"], + json!("./.mcp.json") + ); + let generation_fence = std::env::current_dir() + .unwrap() + .join("plugins/nemo-relay-plugin/.nemo-relay-generation"); + let mcp = plugin_mcp_config( + CodingAgent::Codex, + Path::new("/bin/nemo-relay"), + &generation_fence, + TEST_GENERATION_TOKEN, + ) + .unwrap(); + let server = &mcp["nemo-relay"]; + assert_eq!(server["command"], json!("/bin/nemo-relay")); + assert_eq!(server["args"], json!(["mcp"])); + assert_eq!( + server["env"], + json!({ + "NEMO_RELAY_GATEWAY_BIND": "127.0.0.1:47632", + "NEMO_RELAY_MCP_GENERATION_FILE": &generation_fence, + "NEMO_RELAY_MCP_GENERATION": TEST_GENERATION_TOKEN + }) + ); + assert_eq!(server["required"], json!(true)); + assert_eq!(server["startup_timeout_sec"], json!(20)); + assert!( + server["env_vars"] + .as_array() + .unwrap() + .contains(&json!("OPENAI_API_KEY")) + ); + let claude_mcp = plugin_mcp_config( + CodingAgent::ClaudeCode, + Path::new("/bin/nemo-relay"), + &generation_fence, + TEST_GENERATION_TOKEN, + ); + let claude_server = &claude_mcp.unwrap()["mcpServers"]["nemo-relay"]; + assert_eq!(claude_server["command"], json!("/bin/nemo-relay")); + assert_eq!(claude_server["args"], json!(["mcp"])); + assert_eq!(claude_server["alwaysLoad"], json!(true)); + assert_eq!( + claude_server["env"]["NEMO_RELAY_MCP_GENERATION_FILE"], + json!(&generation_fence) + ); + assert_eq!( + claude_server["env"]["NEMO_RELAY_MCP_GENERATION"], + json!(TEST_GENERATION_TOKEN) + ); + assert_eq!( + plugin_hooks( + CodingAgent::Codex, + Path::new("/bin/nemo-relay"), + &generation_fence, + TEST_GENERATION_TOKEN, + ) + .unwrap()["hooks"]["SessionStart"][0]["hooks"][0]["command"], + json!( + crate::hooks::persistent_hook_forward_command( + Path::new("/bin/nemo-relay"), + CodingAgent::Codex, + &generation_fence, + TEST_GENERATION_TOKEN, + ) + .unwrap() + ) + ); + assert_eq!( + plugin_hooks( + CodingAgent::ClaudeCode, + Path::new("/bin/nemo-relay"), + &generation_fence, + TEST_GENERATION_TOKEN, + ) + .unwrap()["hooks"]["SessionStart"][0]["hooks"][0]["command"], + json!( + crate::hooks::persistent_hook_forward_command( + Path::new("/bin/nemo-relay"), + CodingAgent::ClaudeCode, + &generation_fence, + TEST_GENERATION_TOKEN, + ) + .unwrap() + ) + ); +} + +#[test] +fn relay_identity_prefers_the_path_resolved_executable() { + let dir = tempdir().unwrap(); + let runner = MockRunner::default() + .with_current_executable("/opt/nemo-relay/current/nemo-relay") + .with_executable("nemo-relay", "/opt/nemo-relay/stale/nemo-relay"); + + let relay = require_relay(&options(dir.path()), &runner).unwrap(); + let generation = dir + .path() + .join("plugins/nemo-relay-plugin/.nemo-relay-generation"); + + assert_eq!(relay, PathBuf::from("/opt/nemo-relay/stale/nemo-relay")); + assert_eq!( + plugin_hooks( + CodingAgent::Codex, + &relay, + &generation, + TEST_GENERATION_TOKEN, + ) + .unwrap()["hooks"]["SessionStart"][0]["hooks"][0]["command"], + json!( + crate::hooks::persistent_hook_forward_command( + &relay, + CodingAgent::Codex, + &generation, + TEST_GENERATION_TOKEN, + ) + .unwrap() + ) + ); + assert_eq!( + plugin_mcp_config( + CodingAgent::Codex, + &relay, + &generation, + TEST_GENERATION_TOKEN, + ) + .unwrap()["nemo-relay"]["command"], + json!(relay) + ); +} + +#[test] +fn codex_mcp_env_vars_include_approved_dynamic_and_config_references_only() { + let config = json!({ + "components": [{ + "kind": "observability", + "config": { + "atof": { + "storage": [ + {"header_env": {"authorization": "CUSTOM_HTTP_TOKEN"}}, + {"header_env": { + "blocked": "NEMO_RELAY_PLUGIN_BINARY", + "blocked_mixed_case": "NEMO_RELAY_Plugin_Binary", + "empty": "" + }}, + { + "secret_access_key_var": "CUSTOM_AWS_SECRET", + "session_token_var": "CUSTOM_AWS_SESSION" + }, + { + "secret_access_key_var": "NEMO_RELAY_GATEWAY_BIND", + "session_token_var": "NEMO_RELAY_BOOTSTRAP_SHUTDOWN_TOKEN" + } + ] + } + } + }] + }); + let names = crate::agents::codex_mcp_env_vars_from( + [ + "NEMO_RELAY_CUSTOM_SETTING", + "OTEL_CUSTOM_SETTING", + "AWS_CUSTOM_SETTING", + "NEMO_RELAY_WORKER_TOKEN", + "NEMO_RELAY_PLUGIN_BINARY", + "NEMO_RELAY_Plugin_Binary", + "NEMO_RELAY_GATEWAY_BIND", + "NEMO_RELAY_MCP_GENERATION", + "NEMO_RELAY_MCP_GENERATION_FILE", + "NEMO_RELAY_FAIL_CLOSED", + "NEMO_RELAY_TRANSPARENT_RUN", + "NEMO_RELAY_TEST_CODEX_LOG", + "NEMO_RELAY_Test_CodeX_Log", + ] + .map(str::to_string), + Some(&config), + ); + + assert!(names.is_sorted()); + for expected in [ + "ALL_PROXY", + "OPENAI_API_KEY", + "ANTHROPIC_API_KEY", + "NEMO_RELAY_GATEWAY_URL", + "NEMO_RELAY_TRANSPARENT_RUN", + "NEMO_RELAY_CUSTOM_SETTING", + "OTEL_CUSTOM_SETTING", + "AWS_CUSTOM_SETTING", + "CUSTOM_HTTP_TOKEN", + "CUSTOM_AWS_SECRET", + "CUSTOM_AWS_SESSION", + ] { + assert!( + names.iter().any(|name| name == expected), + "missing {expected}" + ); + } + let all_proxy_names = names + .iter() + .filter(|name| name.eq_ignore_ascii_case("ALL_PROXY")) + .collect::>(); + assert_eq!(all_proxy_names.len(), if cfg!(windows) { 1 } else { 2 }); + assert_eq!(names.iter().any(|name| name == "all_proxy"), !cfg!(windows)); + for excluded in [ + "NEMO_RELAY_WORKER_TOKEN", + "NEMO_RELAY_PLUGIN_BINARY", + "NEMO_RELAY_Plugin_Binary", + "NEMO_RELAY_GATEWAY_BIND", + "NEMO_RELAY_MCP_GENERATION", + "NEMO_RELAY_MCP_GENERATION_FILE", + "NEMO_RELAY_BOOTSTRAP_SHUTDOWN_TOKEN", + "NEMO_RELAY_FAIL_CLOSED", + "NEMO_RELAY_TEST_CODEX_LOG", + "NEMO_RELAY_Test_CodeX_Log", + "", + ] { + assert!( + !names.iter().any(|name| name == excluded), + "included {excluded}" + ); + } +} + +#[test] +fn checked_in_codex_mcp_env_vars_match_the_generated_base_allowlist() { + let path = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../integrations/coding-agents/codex/.mcp.json"); + let checked_in: serde_json::Value = + serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); + let checked_in = checked_in["nemo-relay"]["env_vars"] + .as_array() + .unwrap() + .iter() + .map(|value| value.as_str().unwrap().to_string()) + .collect::>(); + + assert_eq!( + checked_in, + crate::mcp_environment::forwarded_names_for_platform(std::iter::empty(), None, false), + "{} drifted from generated MCP environment names", + path.display() + ); +} + +#[test] +fn codex_mcp_env_vars_match_and_deduplicate_names_using_platform_semantics() { + let config = json!({ + "header_env": { + "role": "AWS_ROLE_ARN", + "api_key": "openai_api_key" + } + }); + let environment = ["Aws_Role_Arn", "Otel_Custom_Signal"].map(str::to_string); + + let windows = crate::mcp_environment::forwarded_names_for_platform( + environment.clone(), + Some(&config), + true, + ); + assert!(windows.iter().any(|name| name == "Otel_Custom_Signal")); + assert_eq!( + windows + .iter() + .filter(|name| name.eq_ignore_ascii_case("AWS_ROLE_ARN")) + .count(), + 1 + ); + assert_eq!( + windows + .iter() + .filter(|name| name.eq_ignore_ascii_case("OPENAI_API_KEY")) + .count(), + 1 + ); + assert!(windows.iter().any(|name| name == "OPENAI_API_KEY")); + for proxy in ["ALL_PROXY", "HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY"] { + assert_eq!( + windows + .iter() + .filter(|name| name.eq_ignore_ascii_case(proxy)) + .count(), + 1, + "Windows MCP environment contains duplicate {proxy} spellings" + ); + } + + let unix = + crate::mcp_environment::forwarded_names_for_platform(environment, Some(&config), false); + assert!(!unix.iter().any(|name| name == "Otel_Custom_Signal")); + assert!(!unix.iter().any(|name| name == "Aws_Role_Arn")); + assert!(unix.iter().any(|name| name == "AWS_ROLE_ARN")); + assert!(unix.iter().any(|name| name == "OPENAI_API_KEY")); + assert!(unix.iter().any(|name| name == "openai_api_key")); +} + +#[test] +fn plugin_setup_delegates_and_dry_run_skips_runner_calls() { + let dir = tempdir().unwrap(); + let layout = PluginLayout::new(CodingAgent::Codex, dir.path()); + let setup_runner = MockSetupRunner::default(); + let dry_run = PluginInstallOptions { + dry_run: true, + ..options(dir.path()) + }; + + run_plugin_setup(CodingAgent::Codex, &layout, &dry_run, &setup_runner).unwrap(); + run_plugin_uninstall( + CodingAgent::ClaudeCode, + &layout.plugin_root, + &dry_run, + &setup_runner, + ) + .unwrap(); + run_plugin_doctor( + CodingAgent::Codex, + &layout.plugin_root, + &dry_run, + &setup_runner, + ) + .unwrap(); + uninstall_host_locked( + CodingAgent::Codex, + &dry_run, + &MockRunner::default(), + &setup_runner, + ) + .unwrap(); + assert!(setup_runner.calls().is_empty()); + + let normal = options(dir.path()); + run_plugin_setup(CodingAgent::Codex, &layout, &normal, &setup_runner).unwrap(); + run_plugin_uninstall( + CodingAgent::ClaudeCode, + &layout.plugin_root, + &normal, + &setup_runner, + ) + .unwrap(); + run_plugin_doctor( + CodingAgent::Codex, + &layout.plugin_root, + &normal, + &setup_runner, + ) + .unwrap(); + let report = + run_plugin_doctor_json(CodingAgent::ClaudeCode, &layout.plugin_root, &setup_runner) + .unwrap(); + + assert_eq!( + setup_runner.calls(), + vec![ + format!("setup codex {DEFAULT_GATEWAY_URL}"), + format!("uninstall claude-code {DEFAULT_GATEWAY_URL}"), + format!("doctor codex {DEFAULT_GATEWAY_URL}"), + format!("doctor-json claude-code {DEFAULT_GATEWAY_URL}"), + ] + ); + assert_eq!(report["ok"], json!(true)); +} + +#[test] +fn real_plugin_setup_runner_uses_temp_home_for_claude_paths() { + let dir = tempdir().unwrap(); + let _home = HomeScope::enter(dir.path()); + let runner = HostPluginSetupRunner::new(CodingAgent::ClaudeCode); + let plugin_root = dir.path().join("plugin"); + + runner + .setup("claude-code", DEFAULT_GATEWAY_URL, &plugin_root) + .unwrap(); + assert!( + runner + .doctor("claude-code", DEFAULT_GATEWAY_URL, &plugin_root) + .is_ok() + ); + let claude_report = runner + .doctor_json("claude-code", DEFAULT_GATEWAY_URL, &plugin_root) + .unwrap(); + assert_eq!( + claude_report["checks"]["claude_provider_routing"], + json!(true) + ); + runner + .uninstall("claude-code", DEFAULT_GATEWAY_URL, &plugin_root) + .unwrap(); +} + +#[test] +fn setup_action_descriptions_cover_supported_hosts_and_actions() { + assert_eq!( + CodingAgent::Codex.setup_action_description("configure"), + "configure Codex provider and trust plugin-owned hooks" + ); + assert_eq!( + CodingAgent::Codex.setup_action_description("restore"), + "remove Codex provider and plugin hook trust" + ); + assert_eq!( + CodingAgent::Codex.setup_action_description("doctor"), + "check Codex provider and plugin-owned hooks" + ); + assert_eq!( + CodingAgent::ClaudeCode.setup_action_description("configure"), + "enable Claude Code provider routing through NeMo Relay" + ); + assert_eq!( + CodingAgent::ClaudeCode.setup_action_description("restore"), + "restore Claude Code provider routing from NeMo Relay backup" + ); + assert_eq!( + CodingAgent::ClaudeCode.setup_action_description("doctor"), + "check Claude Code provider routing" + ); +} + +#[test] +fn host_command_helpers_cover_dry_run_missing_failure_and_reporting() { + let dir = tempdir().unwrap(); + let dry_run = PluginInstallOptions { + dry_run: true, + ..options(dir.path()) + }; + let runner = MockRunner::default(); + + assert_eq!( + require_relay(&dry_run, &runner).unwrap(), + PathBuf::from(RELAY_COMMAND) + ); + require_host_cli(CodingAgent::Codex, &dry_run, &runner).unwrap(); + validate_host_version(CodingAgent::ClaudeCode, &dry_run, &runner).unwrap(); + validate_relay_hook_forward(Path::new("nemo-relay"), &dry_run, &runner).unwrap(); + validate_relay_mcp(Path::new("nemo-relay"), &dry_run, &runner).unwrap(); + run_command( + "codex", + &["plugin".into(), "add space".into()], + &dry_run, + &runner, + ) + .unwrap(); + run_path_command( + Path::new("/bin/codex"), + &["arg with space".into()], + &dry_run, + &runner, + ) + .unwrap(); + let capture = run_capture_command("codex", &["plugin".into()], &dry_run, &runner).unwrap(); + assert_eq!(capture.stdout, "null\n"); + let report = host_registration_report(CodingAgent::Codex, &dry_run, &runner).unwrap(); + assert!(report.ok()); + assert_eq!(report.to_json()["ok"], json!(true)); + assert_eq!( + HostRegistrationReport { + host_plugin_registered: false, + host_marketplace_registered: true, + } + .to_json()["host_plugin_registered"], + json!(false) + ); + + let normal = options(dir.path()); + assert!( + require_relay(&normal, &runner) + .unwrap_err() + .contains("nemo-relay") + ); + assert!( + require_host_cli(CodingAgent::Codex, &normal, &runner) + .unwrap_err() + .contains("codex") + ); + assert!( + run_command("codex", &["plugin".into()], &normal, &runner) + .unwrap_err() + .contains("codex") + ); + + let mut runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("codex", "/bin/codex"); + runner.failing_quiet_suffix = Some("hook-forward --help".into()); + assert!( + validate_relay_hook_forward(Path::new("/bin/nemo-relay"), &normal, &runner) + .unwrap_err() + .contains("hook-forward") + ); + runner.failing_quiet_suffix = Some("mcp --help".into()); + assert!( + validate_relay_mcp(Path::new("/bin/nemo-relay"), &normal, &runner) + .unwrap_err() + .contains("nemo-relay mcp") + ); + runner.failing_suffix = Some("plugin add".into()); + assert!( + run_path_command( + Path::new("/bin/codex"), + &["plugin".into(), "add".into()], + &normal, + &runner + ) + .unwrap_err() + .contains("exit code 1") + ); + let quoted = format_command( + "codex", + &["plugin".into(), "arg with space".into(), "quote\"$".into()], + ); + #[cfg(not(windows))] + assert!(quoted.contains("'arg with space'")); + #[cfg(not(windows))] + assert!(quoted.contains("'quote\"$'")); + #[cfg(windows)] + assert!(quoted.contains("\"arg with space\"")); + #[cfg(windows)] + assert!(quoted.contains("\"quote\"\"$\"")); + + let runner = MockRunner::default() + .with_executable("codex", "/bin/codex") + .with_capture_status("/bin/codex plugin bad", 2, "", "") + .with_capture_status("/bin/codex plugin noisy", 3, "", "boom"); + assert!( + run_capture_command("codex", &["plugin".into(), "bad".into()], &normal, &runner) + .unwrap_err() + .contains("exit code 2") + ); + assert!( + run_capture_command( + "codex", + &["plugin".into(), "noisy".into()], + &normal, + &runner + ) + .unwrap_err() + .contains(": boom") + ); + + let runner = MockRunner::default() + .with_executable("codex", "/bin/codex") + .with_capture_output("/bin/codex plugin list", "PLUGIN STATUS VERSION PATH\n") + .with_capture_output("/bin/codex plugin marketplace list", "MARKETPLACE ROOT\n"); + let error = validate_host_registration(CodingAgent::Codex, &normal, &runner).unwrap_err(); + assert!( + error.contains("host plugin") && error.contains("host marketplace"), + "error was: {error}" + ); +} + +#[test] +fn host_registration_report_accepts_claude_and_codex_shape_variants() { + let dir = tempdir().unwrap(); + let normal = options(dir.path()); + let plugin_id = format!("{PLUGIN_NAME}@{MARKETPLACE_NAME}"); + + for (plugin_entry, marketplace_entry) in [ + ( + json!({"id": plugin_id.clone()}), + json!({"id": MARKETPLACE_NAME}), + ), + ( + json!({"pluginId": plugin_id.clone()}), + json!({"name": MARKETPLACE_NAME}), + ), + ( + json!({"name": PLUGIN_NAME, "marketplaceName": MARKETPLACE_NAME}), + json!({"id": MARKETPLACE_NAME}), + ), + ] { + let runner = MockRunner::default() + .with_executable("claude", "/bin/claude") + .with_capture_output( + "/bin/claude plugin list --json", + json!([plugin_entry]).to_string(), + ) + .with_capture_output( + "/bin/claude plugin marketplace list --json", + json!([marketplace_entry]).to_string(), + ); + let report = host_registration_report(CodingAgent::ClaudeCode, &normal, &runner).unwrap(); + assert!(report.ok()); + assert!(report.host_plugin_registered); + assert!(report.host_marketplace_registered); + } + + let runner = MockRunner::default() + .with_executable("codex", "/bin/codex") + .with_capture_output( + "/bin/codex plugin list", + format!("{plugin_id} installed, enabled 0.4.0 /tmp/nemo-relay-plugin\n"), + ) + .with_capture_output( + "/bin/codex plugin marketplace list", + format!("{MARKETPLACE_NAME} /tmp/nemo-relay-local\n"), + ); + let report = host_registration_report(CodingAgent::Codex, &normal, &runner).unwrap(); + assert!(report.ok()); + + let runner = MockRunner::default() + .with_executable("codex", "/bin/codex") + .with_capture_output( + "/bin/codex plugin list", + format!("{plugin_id} not installed\n"), + ) + .with_capture_output( + "/bin/codex plugin marketplace list", + format!("{MARKETPLACE_NAME} /tmp/nemo-relay-local\n"), + ); + let report = host_registration_report(CodingAgent::Codex, &normal, &runner).unwrap(); + assert!(!report.host_plugin_registered); + assert!(report.host_marketplace_registered); + + let runner = MockRunner::default() + .with_executable("codex", "/bin/codex") + .with_capture_output( + "/bin/codex plugin list", + format!("{PLUGIN_NAME}@other installed, enabled 0.4.0 /tmp/other\n"), + ) + .with_capture_output("/bin/codex plugin marketplace list", "other /tmp/other\n"); + let report = host_registration_report(CodingAgent::Codex, &normal, &runner).unwrap(); + assert!(!report.ok()); + assert!(!report.host_plugin_registered); + assert!(!report.host_marketplace_registered); +} + +#[test] +fn host_registration_report_surfaces_capture_status_and_stderr_variants() { + let dir = tempdir().unwrap(); + let normal = options(dir.path()); + + let runner = MockRunner::default() + .with_executable("claude", "/bin/claude") + .with_capture_output("/bin/claude plugin list --json", "not json"); + assert!( + host_registration_report(CodingAgent::ClaudeCode, &normal, &runner) + .unwrap_err() + .contains("failed to parse") + ); + + let runner = MockRunner::default() + .with_executable("claude", "/bin/claude") + .with_capture_status( + "/bin/claude plugin list --json", + 4, + "ignored stdout", + " noisy failure \n", + ); + let error = host_registration_report(CodingAgent::ClaudeCode, &normal, &runner).unwrap_err(); + assert!(error.contains("exit code 4: noisy failure")); + + let runner = MockRunner::default() + .with_executable("claude", "/bin/claude") + .with_capture_output( + "/bin/claude plugin list --json", + json!([{ "id": format!("{PLUGIN_NAME}@{MARKETPLACE_NAME}") }]).to_string(), + ) + .with_capture_status( + "/bin/claude plugin marketplace list --json", + 5, + "ignored stdout", + "", + ); + let error = host_registration_report(CodingAgent::ClaudeCode, &normal, &runner).unwrap_err(); + assert!(error.contains("exit code 5")); + assert!(!error.contains("exit code 5:")); +} + +#[test] +fn top_level_install_uninstall_and_doctor_report_empty_host_selection() { + let dir = tempdir().unwrap(); + let empty_path = dir.path().join("empty-path"); + std::fs::create_dir_all(&empty_path).unwrap(); + let _path = PathScope::set_isolated(&empty_path, &dir.path().join("home")); + + assert!(crate::agents::detected_install_integrations(&CodingAgent::ALL).is_empty()); + assert!( + crate::agents::installed_integrations( + &CodingAgent::ALL, + Some(&dir.path().join("install")), + ) + .is_empty() + ); + + assert_eq!( + install( + CodingAgent::Codex, + crate::installation::InstallRequest { + install_dir: Some(dir.path().join("dry-run-install")), + force: false, + dry_run: true, + skip_doctor: true, + } + ) + .unwrap(), + std::process::ExitCode::SUCCESS + ); + + let doctor_options = plugin_doctor_options(Some(dir.path().join("install"))); + let codex_doctor_error = crate::agents::doctor_integration(CodingAgent::Codex, &doctor_options) + .unwrap_err() + .to_string(); + assert!( + codex_doctor_error.contains("nemo-relay install codex --force"), + "error was: {codex_doctor_error}" + ); + + assert_eq!(CodingAgent::Codex.as_arg(), "codex"); + assert_eq!(CodingAgent::Codex.label(), "Codex"); + assert_eq!(CodingAgent::Codex.executable(), "codex"); +} + +#[test] +fn installed_selection_uses_persisted_integration_state() { + let dir = tempdir().unwrap(); + let home = dir.path().join("home"); + std::fs::create_dir_all(home.join(".hermes")).unwrap(); + let _home = HomeScope::enter(&home); + std::fs::write( + state_path(CodingAgent::ClaudeCode, dir.path()), + r#"{"marketplaceRoot":"/tmp/m","pluginRoot":"/tmp/p"}"#, + ) + .unwrap(); + let selected = crate::agents::installed_integrations(&CodingAgent::ALL, Some(dir.path())); + assert_eq!(selected, vec![CodingAgent::ClaudeCode]); + + let unrelated_hermes_config = b"# user-owned formatting\nmodel: custom\n"; + let hermes_config = home.join(".hermes/config.yaml"); + std::fs::write(&hermes_config, unrelated_hermes_config).unwrap(); + let selected = crate::agents::installed_integrations(&CodingAgent::ALL, Some(dir.path())); + assert_eq!(selected, vec![CodingAgent::ClaudeCode]); + assert_eq!( + std::fs::read(&hermes_config).unwrap(), + unrelated_hermes_config + ); +} + +#[test] +fn hermes_doctor_probes_the_configured_relay_and_top_level_doctor_discovers_it() { + let dir = tempdir().unwrap(); + let home = dir.path().join("home"); + std::fs::create_dir_all(&home).unwrap(); + let _home = HomeScope::enter(&home); + let config = crate::agents::hermes::install::config_path().unwrap(); + let relay = home + .join("bin") + .join(format!("nemo-relay{}", std::env::consts::EXE_SUFFIX)); + std::fs::create_dir_all(relay.parent().unwrap()).unwrap(); + std::fs::copy(std::env::current_exe().unwrap(), &relay).unwrap(); + crate::agents::hermes::install_persistent(&config, &relay).unwrap(); + let configured_relay = crate::agents::hermes::configured_relay_executable(&config).unwrap(); + let runner = MockRunner::default() + .with_executable("hermes", "/bin/hermes") + .with_capture_output("/bin/hermes --version", "Hermes Agent v0.18.2 (test)\n"); + + let report = + crate::agents::hermes::install::doctor_json_value(&options(dir.path()), &runner).unwrap(); + + assert_eq!(report["ok"], json!(true)); + assert_eq!( + runner.quiet_commands(), + vec![ + format!("{} hook-forward --help", configured_relay.display()), + format!("{} mcp --help", configured_relay.display()), + ] + ); + let checks = report["readiness_checks"].as_array().unwrap(); + for expected in [ + "Host CLI", + "Hermes Agent version", + "Configured Relay binary", + "Relay hook support", + "Relay MCP support", + "Hermes MCP, hooks, and trust", + ] { + assert!( + checks + .iter() + .any(|check| check["name"] == expected && check["ok"] == json!(true)), + "missing successful {expected} check: {checks:?}" + ); + } + + let readiness = crate::agents::collect_default_integration_readiness(); + let hermes = readiness + .iter() + .find(|readiness| readiness.host == "hermes") + .expect("top-level doctor should discover install-only Hermes state"); + assert_eq!(hermes.state_path, config); + assert!(hermes.marketplace.is_none()); + assert!(hermes.plugin.is_none()); + + crate::agents::hermes::install::doctor(&options(dir.path()), &runner).unwrap(); + + std::fs::remove_file(&configured_relay).unwrap(); + let error = crate::agents::hermes::install::doctor(&options(dir.path()), &runner).unwrap_err(); + assert!(error.contains("doctor checks failed"), "{error}"); + let report = + crate::agents::hermes::install::doctor_json_value(&options(dir.path()), &runner).unwrap(); + let failed = report["readiness_checks"].as_array().unwrap(); + for expected in [ + "Configured Relay binary", + "Relay hook support", + "Relay MCP support", + ] { + assert!( + failed + .iter() + .any(|check| check["name"] == expected && check["ok"] == json!(false)), + "missing failed {expected} check: {failed:?}" + ); + } +} + +#[test] +fn hermes_install_and_uninstall_dry_runs_preserve_persistent_state() { + let dir = tempdir().unwrap(); + let home = dir.path().join("home"); + std::fs::create_dir_all(&home).unwrap(); + let _home = HomeScope::enter(&home); + crate::agents::hermes::install::install(crate::installation::InstallRequest { + install_dir: Some(dir.path().to_path_buf()), + force: false, + dry_run: true, + skip_doctor: false, + }) + .unwrap(); + let config = crate::agents::hermes::install::config_path().unwrap(); + assert!(!config.exists()); + + let hermes_home = config.parent().unwrap(); + std::fs::create_dir_all(hermes_home).unwrap(); + let allowlist = hermes_home.join("shell-hooks-allowlist.json"); + let generation = hermes_home.join(GENERATION_FILE_NAME); + let sentinels = [ + (&config, b"sentinel config\n".as_slice()), + (&allowlist, b"sentinel allowlist\n".as_slice()), + (&generation, b"sentinel generation\n".as_slice()), + ]; + for (path, contents) in sentinels { + std::fs::write(path, contents).unwrap(); + } + + crate::agents::hermes::install::uninstall(crate::installation::UninstallRequest { + install_dir: Some(dir.path().to_path_buf()), + dry_run: true, + }) + .unwrap(); + + for (path, contents) in sentinels { + assert_eq!(std::fs::read(path).unwrap(), contents); + } +} + +#[test] +fn install_codex_generates_marketplace_and_runs_setup() { + let dir = tempdir().unwrap(); + let runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("codex", "/bin/codex"); + let setup_runner = MockSetupRunner::default(); + + install_host( + CodingAgent::Codex, + &options(dir.path()), + &runner, + &setup_runner, + ) + .unwrap(); + + let layout = PluginLayout::new(CodingAgent::Codex, dir.path()); + let generation = InstallGeneration::capture(layout.generation_fence.clone()).unwrap(); + assert_eq!( + serde_json::from_str::(&std::fs::read_to_string(&layout.hooks_path).unwrap()) + .unwrap(), + plugin_hooks( + CodingAgent::Codex, + Path::new("/bin/nemo-relay"), + &layout.generation_fence, + generation.token(), + ) + .unwrap() + ); + assert_eq!( + runner.commands(), + vec![ + format!( + "/bin/codex plugin marketplace add {}", + layout.marketplace_root.display() + ), + "/bin/codex plugin add nemo-relay-plugin@nemo-relay-local".into(), + ] + ); + assert_eq!( + runner.quiet_commands(), + vec![relay_validation_command(), relay_mcp_validation_command()] + ); + assert_eq!( + serde_json::from_str::(&std::fs::read_to_string(&layout.mcp_config).unwrap()) + .unwrap(), + plugin_mcp_config( + CodingAgent::Codex, + Path::new("/bin/nemo-relay"), + &layout.generation_fence, + generation.token(), + ) + .unwrap() + ); + assert_eq!( + setup_runner.calls(), + vec![format!("setup codex {DEFAULT_GATEWAY_URL}")] + ); +} + +#[test] +fn install_prunes_stale_managed_plugin_root() { + let dir = tempdir().unwrap(); + let runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("claude", "/bin/claude"); + let setup_runner = MockSetupRunner::default(); + let layout = PluginLayout::new(CodingAgent::ClaudeCode, dir.path()); + let stale = layout.plugin_root.join("bin").join("nemo-relay"); + std::fs::create_dir_all(stale.parent().unwrap()).unwrap(); + std::fs::write(&stale, "stale").unwrap(); + + install_host( + CodingAgent::ClaudeCode, + &options(dir.path()), + &runner, + &setup_runner, + ) + .unwrap(); + + assert!(!stale.exists()); + assert!(layout.plugin_manifest.exists()); +} + +#[test] +fn ordinary_codex_reinstall_refuses_a_fenced_install_without_mutating_it() { + let dir = tempdir().unwrap(); + let runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("codex", "/bin/codex") + .with_codex_registration(true, true); + let setup_runner = MockSetupRunner::default(); + write_installed_state(CodingAgent::Codex, dir.path()); + let layout = PluginLayout::new(CodingAgent::Codex, dir.path()); + let sentinel = layout.plugin_root.join("existing-install"); + std::fs::write(&sentinel, b"preserve").unwrap(); + let state = std::fs::read(&layout.state_path).unwrap(); + let generation = std::fs::read(&layout.generation_fence).unwrap(); + + let error = install_host( + CodingAgent::Codex, + &options(dir.path()), + &runner, + &setup_runner, + ) + .unwrap_err(); + + assert!(error.contains("existing fenced Codex plugin"), "{error}"); + assert!( + error.contains("nemo-relay install codex --force"), + "{error}" + ); + assert_eq!(std::fs::read(&sentinel).unwrap(), b"preserve"); + assert_eq!(std::fs::read(&layout.state_path).unwrap(), state); + assert_eq!(std::fs::read(&layout.generation_fence).unwrap(), generation); + assert!(runner.commands().is_empty()); + assert!(setup_runner.calls().is_empty()); + assert_no_install_stage(dir.path()); +} + +#[test] +fn ordinary_codex_reinstall_refuses_a_legacy_install_before_staging() { + let dir = tempdir().unwrap(); + let runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("codex", "/bin/codex") + .with_codex_registration(false, false); + let setup_runner = MockSetupRunner::default(); + write_installed_state(CodingAgent::Codex, dir.path()); + let layout = PluginLayout::new(CodingAgent::Codex, dir.path()); + std::fs::remove_file(&layout.generation_fence).unwrap(); + let state = std::fs::read(&layout.state_path).unwrap(); + + let error = install_host( + CodingAgent::Codex, + &options(dir.path()), + &runner, + &setup_runner, + ) + .unwrap_err(); + + assert_actionable_generation_error(&error, "MCP generation marker is missing"); + assert_eq!(std::fs::read(&layout.state_path).unwrap(), state); + assert!(layout.marketplace_root.exists()); + assert!(runner.commands().is_empty()); + assert!(setup_runner.calls().is_empty()); + assert_no_install_stage(dir.path()); +} + +#[test] +fn ordinary_codex_reinstall_refuses_a_registration_without_local_state() { + let dir = tempdir().unwrap(); + let runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("codex", "/bin/codex") + .with_codex_registration(true, true); + let setup_runner = MockSetupRunner::default(); + + let error = install_host( + CodingAgent::Codex, + &options(dir.path()), + &runner, + &setup_runner, + ) + .unwrap_err(); + + assert_actionable_generation_error(&error, "MCP generation marker is missing"); + assert!(runner.commands().is_empty()); + assert!(setup_runner.calls().is_empty()); + assert_no_install_stage(dir.path()); +} + +#[test] +fn ordinary_codex_reinstall_refuses_a_corrupt_install_before_staging() { + let dir = tempdir().unwrap(); + let runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("codex", "/bin/codex") + .with_codex_registration(false, false); + let setup_runner = MockSetupRunner::default(); + write_installed_state(CodingAgent::Codex, dir.path()); + let layout = PluginLayout::new(CodingAgent::Codex, dir.path()); + std::fs::write(&layout.generation_fence, b"").unwrap(); + let state = std::fs::read(&layout.state_path).unwrap(); + + let error = install_host( + CodingAgent::Codex, + &options(dir.path()), + &runner, + &setup_runner, + ) + .unwrap_err(); + + assert_actionable_generation_error(&error, "is invalid or unreadable"); + assert!(error.contains("is empty"), "{error}"); + assert_eq!(std::fs::read(&layout.state_path).unwrap(), state); + assert!(layout.marketplace_root.exists()); + assert!(runner.commands().is_empty()); + assert!(setup_runner.calls().is_empty()); + assert_no_install_stage(dir.path()); +} + +#[test] +fn force_install_unregisters_existing_host_before_reinstall() { + let dir = tempdir().unwrap(); + let runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("codex", "/bin/codex") + .with_codex_registration(true, true); + let setup_runner = MockSetupRunner::default(); + let options = PluginInstallOptions { + force: true, + ..options(dir.path()) + }; + write_installed_state(CodingAgent::Codex, dir.path()); + + install_host(CodingAgent::Codex, &options, &runner, &setup_runner).unwrap(); + + let commands = runner.commands(); + let remove_index = commands + .iter() + .position(|command| { + command == "/bin/codex plugin remove nemo-relay-plugin@nemo-relay-local" + }) + .unwrap(); + let add_index = commands + .iter() + .position(|command| command.ends_with("plugin add nemo-relay-plugin@nemo-relay-local")) + .unwrap(); + assert!(remove_index < add_index); + assert!( + setup_runner + .calls() + .iter() + .any(|call| call == "snapshot codex") + ); + assert!( + setup_runner + .calls() + .iter() + .any(|call| call == &format!("uninstall codex {DEFAULT_GATEWAY_URL}")) + ); + assert!( + setup_runner + .calls() + .iter() + .any(|call| call == "refresh gateway") + ); + let setup_calls = setup_runner.calls(); + let refresh_index = setup_calls + .iter() + .position(|call| call == "refresh gateway") + .unwrap(); + let snapshot_index = setup_calls + .iter() + .position(|call| call == "snapshot codex") + .unwrap(); + let uninstall_index = setup_calls + .iter() + .position(|call| call == &format!("uninstall codex {DEFAULT_GATEWAY_URL}")) + .unwrap(); + assert!(snapshot_index < uninstall_index); + assert!(uninstall_index < refresh_index); +} + +#[test] +fn force_install_retires_previous_mcp_generation() { + let dir = tempdir().unwrap(); + let runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("codex", "/bin/codex") + .with_codex_registration(true, true); + let setup_runner = MockSetupRunner::default(); + let options = PluginInstallOptions { + force: true, + ..options(dir.path()) + }; + write_installed_state(CodingAgent::Codex, dir.path()); + let layout = PluginLayout::new(CodingAgent::Codex, dir.path()); + let previous = InstallGeneration::capture(layout.generation_fence.clone()).unwrap(); + let previous_token = previous.token().to_string(); + let cached_mcp = + serde_json::from_str::(&std::fs::read_to_string(&layout.mcp_config).unwrap()) + .unwrap(); + let cached_hooks = serde_json::from_str::( + &std::fs::read_to_string(&layout.hooks_path).unwrap(), + ) + .unwrap(); + + install_host(CodingAgent::Codex, &options, &runner, &setup_runner).unwrap(); + + let error = previous.verify_current().unwrap_err(); + assert!(error.contains("has been retired")); + let current = InstallGeneration::capture(layout.generation_fence.clone()).unwrap(); + assert_ne!(current.token(), previous_token); + let mcp = serde_json::from_str::(&std::fs::read_to_string(&layout.mcp_config).unwrap()) + .unwrap(); + assert_eq!( + mcp["nemo-relay"]["env"]["NEMO_RELAY_MCP_GENERATION_FILE"], + json!(layout.generation_fence) + ); + assert_eq!( + mcp["nemo-relay"]["env"]["NEMO_RELAY_MCP_GENERATION"], + json!(current.token()) + ); + assert_eq!( + cached_mcp["nemo-relay"]["env"]["NEMO_RELAY_MCP_GENERATION"], + json!(previous_token) + ); + assert!(crate::hook_assertions::value_has_command_arguments( + &cached_hooks, + &["--generation-token", &previous_token] + )); + let current_hooks = serde_json::from_str::( + &std::fs::read_to_string(&layout.hooks_path).unwrap(), + ) + .unwrap(); + assert!(crate::hook_assertions::value_has_command_arguments( + ¤t_hooks, + &["--generation-token", current.token()] + )); + assert!(layout.generation_lock.exists()); +} + +#[cfg(windows)] +#[test] +fn force_install_reuses_the_same_windows_lock_after_install_dir_canonicalization() { + let root = tempdir().unwrap(); + let requested_install_dir = root.path().join("not-created-yet"); + let first_install_dir = requested_install_dir.clone().canonicalize_or_self(); + assert!(!first_install_dir.exists()); + let runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("codex", "/bin/codex") + .with_codex_registration(false, false); + let setup_runner = MockSetupRunner::default(); + + install_host( + CodingAgent::Codex, + &options(&first_install_dir), + &runner, + &setup_runner, + ) + .unwrap(); + let first_layout = PluginLayout::new(CodingAgent::Codex, &first_install_dir); + let previous = InstallGeneration::capture(first_layout.generation_fence).unwrap(); + + let canonical_install_dir = requested_install_dir.canonicalize().unwrap(); + assert_ne!(first_install_dir, canonical_install_dir); + install_host( + CodingAgent::Codex, + &PluginInstallOptions { + force: true, + ..options(&canonical_install_dir) + }, + &runner, + &setup_runner, + ) + .unwrap(); + + assert!(previous.verify_current().unwrap_err().contains("retired")); + let current_layout = PluginLayout::new(CodingAgent::Codex, &canonical_install_dir); + assert!(current_layout.generation_lock.exists()); + InstallGeneration::capture(current_layout.generation_fence).unwrap(); +} + +#[cfg(windows)] +#[test] +fn force_install_migrates_a_legacy_sibling_lock_before_moving_the_marketplace() { + let dir = tempdir().unwrap(); + let runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("codex", "/bin/codex") + .with_codex_registration(true, true); + let setup_runner = MockSetupRunner::default(); + write_installed_state(CodingAgent::Codex, dir.path()); + let layout = PluginLayout::new(CodingAgent::Codex, dir.path()); + let (previous_token, legacy_lock) = replace_generation_with_legacy_marker(&layout); + + install_host( + CodingAgent::Codex, + &PluginInstallOptions { + force: true, + ..options(dir.path()) + }, + &runner, + &setup_runner, + ) + .unwrap(); + + let current = InstallGeneration::capture(layout.generation_fence).unwrap(); + assert_ne!(current.token(), previous_token); + assert!(layout.generation_lock.exists()); + assert!(!legacy_lock.exists()); +} + +#[cfg(windows)] +#[test] +fn uninstall_releases_a_legacy_sibling_lock_before_removing_the_marketplace() { + let dir = tempdir().unwrap(); + let runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("codex", "/bin/codex"); + let setup_runner = MockSetupRunner::default(); + write_installed_state(CodingAgent::Codex, dir.path()); + let layout = PluginLayout::new(CodingAgent::Codex, dir.path()); + let (_, legacy_lock) = replace_generation_with_legacy_marker(&layout); + + uninstall_host( + CodingAgent::Codex, + &options(dir.path()), + &runner, + &setup_runner, + ) + .unwrap(); + + assert!(!layout.marketplace_root.exists()); + assert!(!layout.state_path.exists()); + assert!(!legacy_lock.exists()); +} + +#[cfg(windows)] +#[test] +fn legacy_force_install_rollback_restores_the_sibling_lock_without_external_residue() { + let dir = tempdir().unwrap(); + let runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("codex", "/bin/codex") + .with_codex_registration(true, true); + let setup_runner = MockSetupRunner { + failing_call: Some(format!("doctor codex {DEFAULT_GATEWAY_URL}")), + ..MockSetupRunner::default() + }; + write_installed_state(CodingAgent::Codex, dir.path()); + let layout = PluginLayout::new(CodingAgent::Codex, dir.path()); + let (previous_token, legacy_lock) = replace_generation_with_legacy_marker(&layout); + + let error = install_host( + CodingAgent::Codex, + &PluginInstallOptions { + force: true, + skip_doctor: false, + ..options(dir.path()) + }, + &runner, + &setup_runner, + ) + .unwrap_err(); + + assert!(error.contains("doctor codex"), "{error}"); + let restored = InstallGeneration::capture(layout.generation_fence).unwrap(); + assert_eq!(restored.token(), previous_token); + assert!(legacy_lock.exists()); + assert!(!layout.generation_lock.exists()); +} + +#[test] +fn claude_force_install_retires_and_replaces_its_mcp_generation() { + let dir = tempdir().unwrap(); + let runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("claude", "/bin/claude") + .with_claude_registration(true, true); + let setup_runner = MockSetupRunner::default(); + let options = PluginInstallOptions { + force: true, + ..options(dir.path()) + }; + write_installed_state(CodingAgent::ClaudeCode, dir.path()); + let layout = PluginLayout::new(CodingAgent::ClaudeCode, dir.path()); + let previous = InstallGeneration::capture(layout.generation_fence.clone()).unwrap(); + let previous_token = previous.token().to_string(); + let cached_mcp = + serde_json::from_str::(&std::fs::read_to_string(&layout.mcp_config).unwrap()) + .unwrap(); + + install_host(CodingAgent::ClaudeCode, &options, &runner, &setup_runner).unwrap(); + + assert!(previous.verify_current().unwrap_err().contains("retired")); + let current = InstallGeneration::capture(layout.generation_fence.clone()).unwrap(); + assert_ne!(current.token(), previous_token); + let mcp = serde_json::from_str::(&std::fs::read_to_string(&layout.mcp_config).unwrap()) + .unwrap(); + assert_eq!( + mcp["mcpServers"]["nemo-relay"]["env"]["NEMO_RELAY_MCP_GENERATION_FILE"], + json!(layout.generation_fence) + ); + assert_eq!( + mcp["mcpServers"]["nemo-relay"]["env"]["NEMO_RELAY_MCP_GENERATION"], + json!(current.token()) + ); + assert_eq!( + cached_mcp["mcpServers"]["nemo-relay"]["env"]["NEMO_RELAY_MCP_GENERATION"], + json!(previous_token) + ); + assert!( + setup_runner + .calls() + .iter() + .any(|call| call == "refresh gateway") + ); +} + +#[test] +fn claude_force_install_rollback_restores_generation_files_and_setup_snapshot() { + let dir = tempdir().unwrap(); + let runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("claude", "/bin/claude") + .with_claude_registration(true, true); + let setup_runner = MockSetupRunner { + failing_call: Some(format!("doctor claude-code {DEFAULT_GATEWAY_URL}")), + ..MockSetupRunner::default() + }; + let options = PluginInstallOptions { + force: true, + skip_doctor: false, + ..options(dir.path()) + }; + write_installed_state(CodingAgent::ClaudeCode, dir.path()); + let layout = PluginLayout::new(CodingAgent::ClaudeCode, dir.path()); + let sentinel = layout.plugin_root.join("previous-install"); + std::fs::write(&sentinel, "restore-exactly").unwrap(); + let original_state = std::fs::read(&layout.state_path).unwrap(); + let previous = InstallGeneration::capture(layout.generation_fence.clone()).unwrap(); + + let error = + install_host(CodingAgent::ClaudeCode, &options, &runner, &setup_runner).unwrap_err(); + + assert!(error.contains("doctor claude-code"), "{error}"); + assert_eq!( + std::fs::read_to_string(&sentinel).unwrap(), + "restore-exactly" + ); + assert_eq!(std::fs::read(&layout.state_path).unwrap(), original_state); + previous.verify_current().unwrap(); + let setup_calls = setup_runner.calls(); + assert!( + setup_calls + .iter() + .any(|call| call == "snapshot claude-code") + ); + assert!(setup_calls.iter().any(|call| call == "restore snapshot")); + assert_eq!( + setup_calls + .iter() + .filter(|call| call.as_str() == "refresh gateway") + .count(), + 2, + "the previous and replacement gateway generations must both be retired: {setup_calls:?}" + ); + assert_no_force_replacement_residue(dir.path()); +} + +#[test] +fn claude_force_install_migrates_a_legacy_hook_only_plugin() { + let dir = tempdir().unwrap(); + let runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("claude", "/bin/claude") + .with_claude_registration(true, true); + let setup_runner = MockSetupRunner::default(); + let options = PluginInstallOptions { + force: true, + ..options(dir.path()) + }; + let layout = PluginLayout::new(CodingAgent::ClaudeCode, dir.path()); + std::fs::create_dir_all(layout.plugin_manifest.parent().unwrap()).unwrap(); + write_json( + &layout.marketplace_manifest, + &marketplace_manifest(CodingAgent::ClaudeCode), + ) + .unwrap(); + let mut legacy_manifest = plugin_manifest(CodingAgent::ClaudeCode); + legacy_manifest + .as_object_mut() + .unwrap() + .remove("mcpServers"); + write_json(&layout.plugin_manifest, &legacy_manifest).unwrap(); + write_state(&layout, &options).unwrap(); + mark_plugin_setup_installed(CodingAgent::ClaudeCode, &layout, &options).unwrap(); + + install_host(CodingAgent::ClaudeCode, &options, &runner, &setup_runner).unwrap(); + + InstallGeneration::capture(layout.generation_fence.clone()).unwrap(); + assert!(layout.mcp_config.is_file()); + let installed = + serde_json::from_str::(&std::fs::read_to_string(&layout.plugin_manifest).unwrap()) + .unwrap(); + assert_eq!(installed["mcpServers"], json!("./.mcp.json")); +} + +#[test] +fn ordinary_claude_reinstall_requires_force_for_a_fenced_plugin() { + let dir = tempdir().unwrap(); + let runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("claude", "/bin/claude") + .with_claude_registration(true, true); + let setup_runner = MockSetupRunner::default(); + write_installed_state(CodingAgent::ClaudeCode, dir.path()); + let layout = PluginLayout::new(CodingAgent::ClaudeCode, dir.path()); + let original_generation = std::fs::read(&layout.generation_fence).unwrap(); + + let error = install_host( + CodingAgent::ClaudeCode, + &options(dir.path()), + &runner, + &setup_runner, + ) + .unwrap_err(); + + assert!(error.contains("nemo-relay install claude-code --force")); + assert_eq!( + std::fs::read(&layout.generation_fence).unwrap(), + original_generation + ); + assert!(runner.commands().is_empty()); + assert!(setup_runner.calls().is_empty()); +} + +#[test] +fn force_install_rejects_persisted_roots_outside_selected_layout() { + let dir = tempdir().unwrap(); + let selected_dir = dir.path().join("selected"); + let relocated_dir = dir.path().join("relocated"); + let relocated = write_relocated_codex_install(&selected_dir, &relocated_dir); + let sentinel = relocated.plugin_root.join("relocated-install"); + std::fs::write(&sentinel, "preserve-until-commit").unwrap(); + let runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("codex", "/bin/codex") + .with_codex_registration(true, true); + let setup_runner = MockSetupRunner::default(); + let install_options = PluginInstallOptions { + force: true, + ..options(&selected_dir) + }; + + let error = + install_host(CodingAgent::Codex, &install_options, &runner, &setup_runner).unwrap_err(); + + let current = PluginLayout::new(CodingAgent::Codex, &selected_dir); + assert!( + error.contains("outside the selected install layout"), + "{error}" + ); + assert!(!current.marketplace_root.exists()); + assert!(relocated.marketplace_root.exists()); + assert!(relocated.generation_lock.exists()); + assert_eq!( + std::fs::read_to_string(&sentinel).unwrap(), + "preserve-until-commit" + ); + assert_no_force_replacement_residue(&selected_dir); + assert_no_force_replacement_residue(&relocated_dir); +} + +#[cfg(unix)] +#[test] +fn persisted_roots_accept_an_equivalent_symlinked_install_path() { + use std::os::unix::fs::symlink; + + let dir = tempdir().unwrap(); + let canonical = dir.path().join("canonical"); + let selected = dir.path().join("selected"); + std::fs::create_dir_all(&canonical).unwrap(); + symlink(&canonical, &selected).unwrap(); + let selected_layout = PluginLayout::new(CodingAgent::Codex, &selected); + std::fs::create_dir_all(&selected_layout.plugin_root).unwrap(); + let canonical_layout = PluginLayout::new(CodingAgent::Codex, &canonical); + let state = PluginState { + marketplace_root: canonical_layout.marketplace_root, + plugin_root: canonical_layout.plugin_root, + host_plugin_removed: false, + host_marketplace_removed: false, + plugin_setup_installed: true, + }; + + selected_layout.validate_persisted_state(&state).unwrap(); +} + +#[test] +fn uninstall_rejects_persisted_roots_outside_selected_layout() { + let dir = tempdir().unwrap(); + let selected_dir = dir.path().join("selected"); + let relocated_dir = dir.path().join("relocated"); + let relocated = write_relocated_codex_install(&selected_dir, &relocated_dir); + let sentinel = relocated.plugin_root.join("relocated-install"); + std::fs::write(&sentinel, "restore-exactly").unwrap(); + let original_state = std::fs::read(state_path(CodingAgent::Codex, &selected_dir)).unwrap(); + let runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("codex", "/bin/codex") + .with_codex_registration(true, true); + let setup_runner = MockSetupRunner::default(); + + let error = uninstall_host( + CodingAgent::Codex, + &options(&selected_dir), + &runner, + &setup_runner, + ) + .unwrap_err(); + + assert!( + error.contains("outside the selected install layout"), + "{error}" + ); + let current = PluginLayout::new(CodingAgent::Codex, &selected_dir); + assert!(!current.marketplace_root.exists()); + assert!(!current.generation_lock.exists()); + assert!(relocated.marketplace_root.exists()); + assert!(relocated.generation_lock.exists()); + assert_eq!( + std::fs::read_to_string(&sentinel).unwrap(), + "restore-exactly" + ); + assert_eq!( + std::fs::read(state_path(CodingAgent::Codex, &selected_dir)).unwrap(), + original_state + ); + assert!(runner.commands().is_empty()); + assert!(setup_runner.calls().is_empty()); + assert_no_force_replacement_residue(&selected_dir); + assert_no_force_replacement_residue(&relocated_dir); +} + +#[test] +fn force_install_rejects_registered_legacy_plugin_without_generation_fence() { + let dir = tempdir().unwrap(); + let runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("codex", "/bin/codex") + .with_capture_output( + "/bin/codex plugin list", + "nemo-relay-plugin@nemo-relay-local installed, enabled\n", + ) + .with_capture_output( + "/bin/codex plugin marketplace list", + "nemo-relay-local /tmp/nemo-relay-local\n", + ); + let setup_runner = MockSetupRunner::default(); + let options = PluginInstallOptions { + force: true, + ..options(dir.path()) + }; + + let error = install_host(CodingAgent::Codex, &options, &runner, &setup_runner).unwrap_err(); + + assert!( + error.contains("MCP generation marker is missing"), + "{error}" + ); + assert!(error.contains("close all Codex clients"), "{error}"); + assert!(error.contains("codex plugin remove"), "{error}"); + assert!(runner.commands().is_empty()); + assert!(setup_runner.calls().is_empty()); + assert_no_install_stage(dir.path()); +} + +#[test] +fn force_install_rejects_unregistered_legacy_plugin_without_generation_fence() { + let dir = tempdir().unwrap(); + let runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("codex", "/bin/codex") + .with_codex_registration(false, false); + let setup_runner = MockSetupRunner::default(); + let options = PluginInstallOptions { + force: true, + ..options(dir.path()) + }; + write_installed_state(CodingAgent::Codex, dir.path()); + let layout = PluginLayout::new(CodingAgent::Codex, dir.path()); + std::fs::remove_file(&layout.generation_fence).unwrap(); + + let error = install_host(CodingAgent::Codex, &options, &runner, &setup_runner).unwrap_err(); + + assert!( + error.contains("MCP generation marker is missing"), + "{error}" + ); + assert!(layout.marketplace_root.exists()); + assert!(layout.state_path.exists()); + assert!(runner.commands().is_empty()); + assert!(setup_runner.calls().is_empty()); + assert_no_install_stage(dir.path()); +} + +#[test] +fn force_install_rejects_corrupt_generation_marker_without_mutating() { + for (corruption, cause) in [ + ("empty", "is empty"), + ("oversized", "exceeds the 128-byte limit"), + ("unreadable", "failed to"), + ] { + let dir = tempdir().unwrap(); + let runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("codex", "/bin/codex") + .with_codex_registration(false, false); + let setup_runner = MockSetupRunner::default(); + let options = PluginInstallOptions { + force: true, + ..options(dir.path()) + }; + write_installed_state(CodingAgent::Codex, dir.path()); + let layout = PluginLayout::new(CodingAgent::Codex, dir.path()); + corrupt_generation_fence(&layout.generation_fence, corruption); + + let error = install_host(CodingAgent::Codex, &options, &runner, &setup_runner).unwrap_err(); + + assert_actionable_generation_error(&error, "is invalid or unreadable"); + assert!(error.contains(cause), "{corruption}: {error}"); + assert!(layout.marketplace_root.exists()); + assert!(layout.state_path.exists()); + assert!(runner.commands().is_empty()); + assert!(setup_runner.calls().is_empty()); + assert_no_install_stage(dir.path()); + } +} + +#[test] +fn force_install_allows_a_clean_first_install_without_generation_fence() { + let dir = tempdir().unwrap(); + let runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("codex", "/bin/codex") + .with_codex_registration(false, false); + let setup_runner = MockSetupRunner::default(); + let options = PluginInstallOptions { + force: true, + ..options(dir.path()) + }; + + install_host(CodingAgent::Codex, &options, &runner, &setup_runner).unwrap(); + + let layout = PluginLayout::new(CodingAgent::Codex, dir.path()); + assert!(layout.generation_fence.exists()); + assert!(layout.state_path.exists()); +} + +#[test] +fn force_install_uses_live_absent_registration_instead_of_stale_installed_state() { + let dir = tempdir().unwrap(); + let runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("codex", "/bin/codex") + .with_codex_registration(false, false); + let setup_runner = MockSetupRunner::default(); + let options = PluginInstallOptions { + force: true, + ..options(dir.path()) + }; + write_installed_state(CodingAgent::Codex, dir.path()); + + install_host(CodingAgent::Codex, &options, &runner, &setup_runner).unwrap(); + + let commands = runner.commands(); + assert!( + commands + .iter() + .all(|command| !command.contains("plugin remove") + && !command.contains("marketplace remove")), + "unexpected removal commands: {commands:?}" + ); + assert!( + commands + .iter() + .any(|command| command.ends_with("plugin add nemo-relay-plugin@nemo-relay-local")) + ); +} + +#[test] +fn force_install_uses_live_present_registration_instead_of_stale_removed_state() { + let dir = tempdir().unwrap(); + let runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("codex", "/bin/codex") + .with_codex_registration(true, true); + let setup_runner = MockSetupRunner::default(); + let options = PluginInstallOptions { + force: true, + ..options(dir.path()) + }; + write_installed_state(CodingAgent::Codex, dir.path()); + let layout = PluginLayout::new(CodingAgent::Codex, dir.path()); + write_state_for_host( + CodingAgent::Codex, + &PluginState { + marketplace_root: layout.marketplace_root.clone(), + plugin_root: layout.plugin_root.clone(), + host_plugin_removed: true, + host_marketplace_removed: true, + plugin_setup_installed: true, + }, + dir.path(), + &options, + ) + .unwrap(); + + install_host(CodingAgent::Codex, &options, &runner, &setup_runner).unwrap(); + + let commands = runner.commands(); + assert!(commands.iter().any(|command| { + command == "/bin/codex plugin remove nemo-relay-plugin@nemo-relay-local" + })); + assert!( + commands + .iter() + .any(|command| { command == "/bin/codex plugin marketplace remove nemo-relay-local" }) + ); +} + +#[test] +fn force_install_commit_does_not_fail_when_backup_cleanup_errors() { + let dir = tempdir().unwrap(); + let backup = dir.path().join("codex-marketplace-backup"); + std::fs::write(&backup, "not a directory").unwrap(); + + ForceInstallSnapshot { + state_bytes: None, + setup_snapshot: None, + plugin_registered: false, + marketplace_registered: false, + original_marketplace_root: dir.path().join("original-marketplace"), + original_plugin_root: dir + .path() + .join("original-marketplace/plugins/nemo-relay-plugin"), + original_generation_fence: dir + .path() + .join("original-marketplace/plugins/nemo-relay-plugin/.nemo-relay-generation"), + backup_marketplace_root: backup.clone(), + backup_plugin_root: None, + marketplace_moved: true, + plugin_moved: false, + replacement_promoted: true, + generation_retirement: None, + } + .commit(&dir.path().join("replacement.lock")); + + assert!(backup.is_file()); +} + +#[test] +fn force_install_keeps_existing_registration_when_gateway_refresh_fails() { + let dir = tempdir().unwrap(); + let runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("codex", "/bin/codex") + .with_codex_registration(true, true); + let setup_runner = MockSetupRunner { + failing_call: Some("refresh gateway".into()), + ..MockSetupRunner::default() + }; + let options = PluginInstallOptions { + force: true, + ..options(dir.path()) + }; + write_installed_state(CodingAgent::Codex, dir.path()); + let layout = PluginLayout::new(CodingAgent::Codex, dir.path()); + let previous = InstallGeneration::capture(layout.generation_fence.clone()).unwrap(); + + let error = install_host(CodingAgent::Codex, &options, &runner, &setup_runner).unwrap_err(); + + assert!(error.contains("refresh gateway failed")); + assert!(layout.state_path.exists()); + assert!(layout.plugin_root.exists()); + previous.verify_current().unwrap(); + assert_eq!( + runner.commands(), + vec![ + "/bin/codex plugin remove nemo-relay-plugin@nemo-relay-local".to_string(), + "/bin/codex plugin marketplace remove nemo-relay-local".to_string(), + ] + ); + assert_eq!( + setup_runner.calls(), + vec![ + "snapshot codex".to_string(), + format!("uninstall codex {DEFAULT_GATEWAY_URL}"), + "refresh gateway".to_string(), + "restore snapshot".to_string(), + ] + ); +} + +#[test] +fn failed_force_refresh_hides_transient_generation_retirement_from_mcp() { + let dir = tempdir().unwrap(); + write_installed_state(CodingAgent::Codex, dir.path()); + let layout = PluginLayout::new(CodingAgent::Codex, dir.path()); + let previous = InstallGeneration::capture(layout.generation_fence.clone()).unwrap(); + let install_dir = dir.path().to_path_buf(); + let (entered_tx, entered_rx) = std::sync::mpsc::channel(); + let (continue_tx, continue_rx) = std::sync::mpsc::channel(); + + let install = std::thread::spawn(move || { + let runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("codex", "/bin/codex") + .with_codex_registration(true, true); + let setup_runner = BlockingRefreshFailure { + entered: entered_tx, + continue_refresh: continue_rx, + }; + let options = PluginInstallOptions { + force: true, + ..options(&install_dir) + }; + install_host(CodingAgent::Codex, &options, &runner, &setup_runner) + }); + + entered_rx.recv_timeout(Duration::from_secs(5)).unwrap(); + let (verified_tx, verified_rx) = std::sync::mpsc::channel(); + let verifier = std::thread::spawn(move || verified_tx.send(previous.verify_current()).unwrap()); + assert!( + verified_rx + .recv_timeout(Duration::from_millis(100)) + .is_err(), + "MCP observed the force-install retirement before refresh committed" + ); + + continue_tx.send(()).unwrap(); + let error = install.join().unwrap().unwrap_err(); + assert!(error.contains("refresh gateway failed"), "{error}"); + verified_rx + .recv_timeout(Duration::from_secs(5)) + .unwrap() + .unwrap(); + verifier.join().unwrap(); + assert!(layout.plugin_root.exists()); +} + +#[test] +fn replacement_retirement_aggregates_refresh_and_restore_failures_without_rewriting_new_tree() { + let dir = tempdir().unwrap(); + write_installed_state(CodingAgent::Codex, dir.path()); + let layout = PluginLayout::new(CodingAgent::Codex, dir.path()); + let backup = dir.path().join("replacement-backup"); + let install_dir = dir.path().to_path_buf(); + let retirement_layout = layout.clone(); + let (entered_tx, entered_rx) = std::sync::mpsc::channel(); + let (continue_tx, continue_rx) = std::sync::mpsc::channel(); + + let retirement = std::thread::spawn(move || { + let setup_runner = BlockingRefreshFailure { + entered: entered_tx, + continue_refresh: continue_rx, + }; + retire_replacement_before_rollback( + CodingAgent::Codex, + &retirement_layout, + &options(&install_dir), + &setup_runner, + None, + ) + }); + + entered_rx.recv_timeout(Duration::from_secs(1)).unwrap(); + std::fs::rename(&layout.plugin_root, &backup).unwrap(); + let replacement_token = crate::installation::generation::write_staged_generation_with_token( + &layout.generation_fence, + &layout.generation_lock, + ) + .unwrap(); + let replacement_marker = std::fs::read(&layout.generation_fence).unwrap(); + continue_tx.send(()).unwrap(); + + let error = match retirement.join().unwrap() { + Ok(_) => panic!("replacement retirement unexpectedly succeeded"), + Err(error) => error, + }; + assert!(error.contains("refresh gateway failed"), "{error}"); + assert!(error.contains("lock identity changed"), "{error}"); + assert_eq!( + std::fs::read(&layout.generation_fence).unwrap(), + replacement_marker + ); + assert_eq!( + InstallGeneration::capture(layout.generation_fence) + .unwrap() + .token(), + replacement_token + ); +} + +#[test] +fn uninstall_restores_mcp_generation_when_gateway_refresh_fails() { + let dir = tempdir().unwrap(); + let runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("codex", "/bin/codex"); + let setup_runner = MockSetupRunner { + failing_call: Some("refresh gateway".into()), + ..MockSetupRunner::default() + }; + let options = options(dir.path()); + write_installed_state(CodingAgent::Codex, dir.path()); + let layout = PluginLayout::new(CodingAgent::Codex, dir.path()); + let previous = InstallGeneration::capture(layout.generation_fence.clone()).unwrap(); + + let error = uninstall_host(CodingAgent::Codex, &options, &runner, &setup_runner).unwrap_err(); + + assert!(error.contains("refresh gateway failed")); + previous.verify_current().unwrap(); + assert!(layout.state_path.exists()); + assert!(layout.plugin_root.exists()); + assert_eq!(setup_runner.calls(), vec!["refresh gateway"]); + assert!(runner.commands().is_empty()); +} + +#[test] +fn force_install_restores_previous_install_after_doctor_failure() { + let dir = tempdir().unwrap(); + let runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("codex", "/bin/codex") + .with_codex_registration(true, true); + let setup_runner = MockSetupRunner { + failing_call: Some(format!("doctor codex {DEFAULT_GATEWAY_URL}")), + ..MockSetupRunner::default() + }; + let options = PluginInstallOptions { + force: true, + skip_doctor: false, + ..options(dir.path()) + }; + write_installed_state(CodingAgent::Codex, dir.path()); + let layout = PluginLayout::new(CodingAgent::Codex, dir.path()); + let sentinel = layout.plugin_root.join("previous-install"); + std::fs::write(&sentinel, "preserve").unwrap(); + let original_state = std::fs::read(&layout.state_path).unwrap(); + + let error = install_host(CodingAgent::Codex, &options, &runner, &setup_runner).unwrap_err(); + + assert!(error.contains("doctor codex"), "{error}"); + assert_eq!(std::fs::read_to_string(sentinel).unwrap(), "preserve"); + assert_eq!(std::fs::read(&layout.state_path).unwrap(), original_state); + assert!( + setup_runner + .calls() + .iter() + .any(|call| call == "restore snapshot") + ); + let setup_calls = setup_runner.calls(); + let refreshes = setup_calls + .iter() + .enumerate() + .filter(|(_, call)| call.as_str() == "refresh gateway") + .map(|(index, _)| index) + .collect::>(); + assert_eq!( + refreshes.len(), + 2, + "the previous and replacement MCP generations must each be stopped: {setup_calls:?}" + ); + let restore_index = setup_calls + .iter() + .position(|call| call == "restore snapshot") + .unwrap(); + assert!(refreshes[1] < restore_index); + assert!(std::fs::read_dir(dir.path()).unwrap().all(|entry| { + let name = entry.unwrap().file_name(); + let name = name.to_string_lossy(); + !name.contains("install-stage") && !name.contains("marketplace-backup") + })); +} + +#[test] +fn force_install_restores_previous_install_after_state_write_failure() { + let dir = tempdir().unwrap(); + let runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("codex", "/bin/codex") + .with_codex_registration(true, true); + let install_options = PluginInstallOptions { + force: true, + ..options(dir.path()) + }; + write_installed_state(CodingAgent::Codex, dir.path()); + let layout = PluginLayout::new(CodingAgent::Codex, dir.path()); + let sentinel = layout.plugin_root.join("previous-install"); + std::fs::write(&sentinel, "preserve").unwrap(); + let original_state = std::fs::read(&layout.state_path).unwrap(); + let previous = InstallGeneration::capture(layout.generation_fence.clone()).unwrap(); + let setup_runner = FailStateWriteAfterRefresh { + state_path: layout.state_path.clone(), + injected: Cell::new(false), + }; + + let error = + install_host(CodingAgent::Codex, &install_options, &runner, &setup_runner).unwrap_err(); + + assert!(error.contains("injected test failure"), "{error}"); + assert_eq!(std::fs::read_to_string(sentinel).unwrap(), "preserve"); + assert_eq!(std::fs::read(&layout.state_path).unwrap(), original_state); + previous.verify_current().unwrap(); + assert_no_force_replacement_residue(dir.path()); +} + +#[test] +fn first_install_cleans_generated_marketplace_after_state_write_failure() { + let dir = tempdir().unwrap(); + let runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("codex", "/bin/codex"); + let layout = PluginLayout::new(CodingAgent::Codex, dir.path()); + crate::filesystem::fail_next_atomic_write(&layout.state_path); + + let error = install_host( + CodingAgent::Codex, + &options(dir.path()), + &runner, + &MockSetupRunner::default(), + ) + .unwrap_err(); + + assert!(error.contains("injected test failure"), "{error}"); + assert!(!layout.marketplace_root.exists()); + assert!(!layout.state_path.exists()); + assert!(!layout.generation_lock.exists()); +} + +#[test] +fn force_replacement_restoration_aggregates_independent_cleanup_failures() { + let dir = tempdir().unwrap(); + let install_file = dir.path().join("install-file"); + std::fs::write(&install_file, "not a directory").unwrap(); + let layout = PluginLayout::new(CodingAgent::Codex, &install_file); + let original_marketplace_root = dir.path().join("original-marketplace"); + let original_plugin_root = dir.path().join("original-plugin"); + let mut snapshot = ForceInstallSnapshot { + state_bytes: Some(b"original state".to_vec()), + setup_snapshot: Some(PluginSetupSnapshot::Mock), + original_marketplace_root: original_marketplace_root.clone(), + original_plugin_root: original_plugin_root.clone(), + original_generation_fence: original_plugin_root.join(GENERATION_FILE_NAME), + plugin_registered: false, + marketplace_registered: false, + backup_marketplace_root: dir.path().join("missing-marketplace-backup"), + backup_plugin_root: Some(dir.path().join("missing-plugin-backup")), + marketplace_moved: true, + plugin_moved: true, + replacement_promoted: true, + generation_retirement: None, + }; + let mut runner = MockRunner::default() + .with_executable("codex", "/bin/codex") + .with_codex_registration(true, true); + runner.failing_suffixes = vec![ + "plugin remove nemo-relay-plugin@nemo-relay-local".into(), + "plugin marketplace remove nemo-relay-local".into(), + ]; + let setup_runner = MockSetupRunner { + failing_call: Some("restore snapshot".into()), + ..MockSetupRunner::default() + }; + + let error = restore_force_replacement_after_error::<()>( + CodingAgent::Codex, + &layout, + &mut snapshot, + &options(&install_file), + &runner, + &setup_runner, + "replacement failed".into(), + ) + .unwrap_err(); + + assert!(error.contains("replacement failed"), "{error}"); + assert!( + error.contains("failed to restore previous install"), + "{error}" + ); + assert!(error.contains("plugin remove"), "{error}"); + assert!(error.contains("plugin marketplace remove"), "{error}"); + assert!(error.contains("failed to restore marketplace"), "{error}"); + assert!(error.contains("failed to restore plugin root"), "{error}"); + assert!(error.contains("restore snapshot failed"), "{error}"); + assert!(error.contains("failed to restore"), "{error}"); +} + +#[test] +fn force_replacement_restoration_reports_failed_host_reregistration() { + let dir = tempdir().unwrap(); + let layout = PluginLayout::new(CodingAgent::Codex, dir.path()); + let original_marketplace_root = dir.path().join("original-marketplace"); + let mut snapshot = ForceInstallSnapshot { + state_bytes: None, + setup_snapshot: None, + original_marketplace_root: original_marketplace_root.clone(), + original_plugin_root: original_marketplace_root.join("plugins/nemo-relay-plugin"), + original_generation_fence: original_marketplace_root.join(GENERATION_FILE_NAME), + plugin_registered: true, + marketplace_registered: true, + backup_marketplace_root: dir.path().join("unused-marketplace-backup"), + backup_plugin_root: None, + marketplace_moved: false, + plugin_moved: false, + replacement_promoted: false, + generation_retirement: None, + }; + let mut runner = MockRunner::default() + .with_executable("codex", "/bin/codex") + .with_codex_registration(false, false); + runner.failing_suffixes = vec![ + format!( + "plugin marketplace add {}", + original_marketplace_root.display() + ), + "plugin add nemo-relay-plugin@nemo-relay-local".into(), + ]; + + let error = restore_force_replacement( + CodingAgent::Codex, + &layout, + &mut snapshot, + &options(dir.path()), + &runner, + &MockSetupRunner::default(), + ) + .unwrap_err(); + + assert!(error.contains("plugin marketplace add"), "{error}"); + assert!(error.contains("plugin add"), "{error}"); +} + +#[test] +fn force_replacement_moves_and_restores_a_separate_plugin_tree() { + let dir = tempdir().unwrap(); + let previous_marketplace_root = dir.path().join("previous-marketplace"); + let previous_plugin_root = dir.path().join("relocated-plugin"); + std::fs::create_dir_all(&previous_marketplace_root).unwrap(); + std::fs::create_dir_all(&previous_plugin_root).unwrap(); + std::fs::write( + previous_marketplace_root.join("marketplace.json"), + "marketplace", + ) + .unwrap(); + std::fs::write(previous_plugin_root.join("plugin.json"), "plugin").unwrap(); + let target = PluginLayout::new(CodingAgent::Codex, &dir.path().join("target")); + let preflight = PluginInstallPreflight { + persisted: None, + state_bytes: None, + previous_marketplace_root: previous_marketplace_root.clone(), + previous_plugin_root: previous_plugin_root.clone(), + previous_generation_fence: previous_plugin_root.join(GENERATION_FILE_NAME), + plugin_registered: false, + marketplace_registered: false, + previous_setup_installed: false, + previous_install_exists: true, + generation_retirement: None, + }; + let setup_runner = MockSetupRunner::default(); + let runner = MockRunner::default().with_executable("codex", "/bin/codex"); + let mut snapshot = begin_force_replacement( + CodingAgent::Codex, + &target, + preflight, + &options(dir.path()), + &runner, + &setup_runner, + ) + .unwrap(); + + assert!(snapshot.marketplace_moved); + assert!(snapshot.plugin_moved); + assert!(!previous_marketplace_root.exists()); + assert!(!previous_plugin_root.exists()); + + restore_force_replacement( + CodingAgent::Codex, + &target, + &mut snapshot, + &options(dir.path()), + &runner, + &setup_runner, + ) + .unwrap(); + + assert_eq!( + std::fs::read_to_string(previous_marketplace_root.join("marketplace.json")).unwrap(), + "marketplace" + ); + assert_eq!( + std::fs::read_to_string(previous_plugin_root.join("plugin.json")).unwrap(), + "plugin" + ); +} + +#[test] +fn force_install_cleans_only_the_previous_setup_after_replacement_setup_failure() { + let dir = tempdir().unwrap(); + let runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("codex", "/bin/codex") + .with_codex_registration(true, true); + let setup_runner = MockSetupRunner { + failing_call: Some(format!("setup codex {DEFAULT_GATEWAY_URL}")), + ..MockSetupRunner::default() + }; + let options = PluginInstallOptions { + force: true, + ..options(dir.path()) + }; + write_installed_state(CodingAgent::Codex, dir.path()); + let layout = PluginLayout::new(CodingAgent::Codex, dir.path()); + let sentinel = layout.plugin_root.join("previous-install"); + std::fs::write(&sentinel, "preserve").unwrap(); + let original_state = std::fs::read(&layout.state_path).unwrap(); + + let error = install_host(CodingAgent::Codex, &options, &runner, &setup_runner).unwrap_err(); + + assert!(error.contains("setup codex"), "{error}"); + assert_eq!(std::fs::read_to_string(sentinel).unwrap(), "preserve"); + assert_eq!(std::fs::read(&layout.state_path).unwrap(), original_state); + assert!( + setup_runner + .calls() + .iter() + .any(|call| call == "restore snapshot") + ); + assert_eq!( + setup_runner + .calls() + .iter() + .filter(|call| call.as_str() == format!("uninstall codex {DEFAULT_GATEWAY_URL}")) + .count(), + 1, + "only the previous setup should be removed before replacement registration" + ); +} + +#[test] +fn first_install_removes_a_partially_written_marketplace() { + let dir = tempdir().unwrap(); + let runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("codex", "/bin/codex"); + let setup_runner = MockSetupRunner::default(); + let layout = PluginLayout::new(CodingAgent::Codex, dir.path()); + crate::filesystem::fail_next_atomic_write(&layout.plugin_manifest); + + let error = install_host( + CodingAgent::Codex, + &options(dir.path()), + &runner, + &setup_runner, + ) + .unwrap_err(); + + assert!(error.contains("injected test failure"), "{error}"); + assert!(!layout.marketplace_root.exists()); + assert!(!layout.state_path.exists()); + assert!(!layout.generation_lock.exists()); + assert!(runner.commands().is_empty()); + assert!(setup_runner.calls().is_empty()); +} + +#[test] +fn failed_staging_removes_a_new_external_generation_lock() { + let dir = tempdir().unwrap(); + let target = PluginLayout::new(CodingAgent::Codex, dir.path()); + let stage_parent = dir.path().join("deterministic-stage"); + let staged = PluginLayout::new(CodingAgent::Codex, &stage_parent); + crate::filesystem::fail_next_atomic_write(&staged.mcp_config); + + let error = match stage_plugin_marketplace_at( + CodingAgent::Codex, + Path::new("/bin/nemo-relay"), + &target, + true, + &options(dir.path()), + stage_parent.clone(), + ) { + Ok(_) => panic!("staging unexpectedly succeeded"), + Err(error) => error, + }; + + assert!(error.contains("injected test failure"), "{error}"); + assert!(!stage_parent.exists()); + assert!(!target.generation_lock.exists()); +} + +#[test] +fn failed_staging_preserves_a_preexisting_external_generation_lock() { + let dir = tempdir().unwrap(); + let target = PluginLayout::new(CodingAgent::Codex, dir.path()); + let orphan_marker = dir.path().join("orphan-generation"); + crate::installation::generation::write_new_generation_with_token_at( + &orphan_marker, + &target.generation_lock, + ) + .unwrap(); + std::fs::remove_file(orphan_marker).unwrap(); + let original_lock = std::fs::read(&target.generation_lock).unwrap(); + let stage_parent = dir.path().join("deterministic-existing-lock-stage"); + let staged = PluginLayout::new(CodingAgent::Codex, &stage_parent); + crate::filesystem::fail_next_atomic_write(&staged.mcp_config); + + let error = match stage_plugin_marketplace_at( + CodingAgent::Codex, + Path::new("/bin/nemo-relay"), + &target, + true, + &options(dir.path()), + stage_parent.clone(), + ) { + Ok(_) => panic!("staging unexpectedly succeeded"), + Err(error) => error, + }; + + assert!(error.contains("injected test failure"), "{error}"); + assert!(!stage_parent.exists()); + assert_eq!( + std::fs::read(target.generation_lock).unwrap(), + original_lock + ); +} + +#[cfg(unix)] +#[test] +fn failed_staging_preserves_a_preexisting_dangling_generation_lock_symlink() { + use std::os::unix::fs::symlink; + + let dir = tempdir().unwrap(); + let target = PluginLayout::new(CodingAgent::Codex, dir.path()); + let symlink_target = dir.path().join("generation-lock-target"); + symlink(&symlink_target, &target.generation_lock).unwrap(); + let stage_parent = dir.path().join("deterministic-symlink-stage"); + let staged = PluginLayout::new(CodingAgent::Codex, &stage_parent); + crate::filesystem::fail_next_atomic_write(&staged.mcp_config); + + let error = match stage_plugin_marketplace_at( + CodingAgent::Codex, + Path::new("/bin/nemo-relay"), + &target, + true, + &options(dir.path()), + stage_parent.clone(), + ) { + Ok(_) => panic!("staging unexpectedly succeeded"), + Err(error) => error, + }; + + assert!(error.contains("generation lock"), "{error}"); + assert!(!stage_parent.exists()); + assert!( + std::fs::symlink_metadata(&target.generation_lock) + .unwrap() + .file_type() + .is_symlink() + ); + assert!(!symlink_target.exists()); +} + +#[test] +fn install_claude_enables_provider_routing() { + let dir = tempdir().unwrap(); + let runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("claude", "/bin/claude"); + let setup_runner = MockSetupRunner::default(); + + install_host( + CodingAgent::ClaudeCode, + &options(dir.path()), + &runner, + &setup_runner, + ) + .unwrap(); + + let layout = PluginLayout::new(CodingAgent::ClaudeCode, dir.path()); + assert_eq!( + runner.commands(), + vec![ + format!( + "/bin/claude plugin marketplace add {}", + layout.marketplace_root.display() + ), + "/bin/claude plugin install nemo-relay-plugin@nemo-relay-local --scope user".into(), + ] + ); + assert_eq!( + runner.quiet_commands(), + vec![relay_validation_command(), relay_mcp_validation_command()] + ); + assert_eq!( + setup_runner.calls(), + vec![format!("setup claude-code {DEFAULT_GATEWAY_URL}")] + ); +} + +#[test] +fn install_claude_rejects_hosts_without_always_load_support_before_writing() { + let dir = tempdir().unwrap(); + let runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("claude", "/bin/claude") + .with_capture_output("/bin/claude --version", "2.1.120 (Claude Code)\n"); + let setup_runner = MockSetupRunner::default(); + + let error = install_host( + CodingAgent::ClaudeCode, + &options(dir.path()), + &runner, + &setup_runner, + ) + .unwrap_err(); + + assert!(error.contains("requires Claude Code 2.1.121"), "{error}"); + let layout = PluginLayout::new(CodingAgent::ClaudeCode, dir.path()); + assert!(!layout.marketplace_root.exists()); + assert!(!layout.state_path.exists()); + assert!(runner.commands().is_empty()); + assert!(setup_runner.calls().is_empty()); +} + +#[test] +fn missing_relay_path_fails_before_generating_plugin() { + let dir = tempdir().unwrap(); + let runner = MockRunner::default().with_executable("codex", "/bin/codex"); + let setup_runner = MockSetupRunner::default(); + + let error = install_host( + CodingAgent::Codex, + &options(dir.path()), + &runner, + &setup_runner, + ) + .unwrap_err(); + + assert!(error.contains("nemo-relay")); + assert!( + !PluginLayout::new(CodingAgent::Codex, dir.path()) + .marketplace_root + .exists() + ); +} + +#[test] +fn unsupported_relay_path_fails_before_generating_plugin() { + let dir = tempdir().unwrap(); + let mut runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("codex", "/bin/codex"); + runner.failing_quiet_suffix = Some("hook-forward --help".into()); + let setup_runner = MockSetupRunner::default(); + + let error = install_host( + CodingAgent::Codex, + &options(dir.path()), + &runner, + &setup_runner, + ) + .unwrap_err(); + + assert!(error.contains("hook-forward")); + assert!( + !PluginLayout::new(CodingAgent::Codex, dir.path()) + .marketplace_root + .exists() + ); +} + +#[test] +fn relay_without_native_mcp_fails_codex_install_before_generating_plugin() { + let dir = tempdir().unwrap(); + let mut runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("codex", "/bin/codex"); + runner.failing_quiet_suffix = Some("mcp --help".into()); + let setup_runner = MockSetupRunner::default(); + + let error = install_host( + CodingAgent::Codex, + &options(dir.path()), + &runner, + &setup_runner, + ) + .unwrap_err(); + + assert!(error.contains("native `nemo-relay mcp` support")); + assert!( + !PluginLayout::new(CodingAgent::Codex, dir.path()) + .marketplace_root + .exists() + ); +} + +#[test] +fn setup_failure_rolls_back_generated_files_and_registration() { + let dir = tempdir().unwrap(); + let runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("claude", "/bin/claude"); + let setup_runner = MockSetupRunner { + failing_call: Some(format!("setup claude-code {DEFAULT_GATEWAY_URL}")), + ..MockSetupRunner::default() + }; + + let error = install_host( + CodingAgent::ClaudeCode, + &options(dir.path()), + &runner, + &setup_runner, + ) + .unwrap_err(); + + assert!(error.contains("setup claude-code")); + let layout = PluginLayout::new(CodingAgent::ClaudeCode, dir.path()); + assert!(!layout.marketplace_root.exists()); + assert!(!layout.generation_lock.exists()); + assert!( + runner + .commands() + .iter() + .any(|command| command == "/bin/claude plugin uninstall nemo-relay-plugin") + ); + assert!( + setup_runner + .calls() + .iter() + .any(|call| call == &format!("uninstall claude-code {DEFAULT_GATEWAY_URL}")) + ); +} + +#[test] +fn doctor_failure_fails_install_and_rolls_back() { + let dir = tempdir().unwrap(); + let runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("claude", "/bin/claude"); + let setup_runner = MockSetupRunner { + failing_call: Some(format!("doctor claude-code {DEFAULT_GATEWAY_URL}")), + ..MockSetupRunner::default() + }; + let options = PluginInstallOptions { + skip_doctor: false, + ..options(dir.path()) + }; + + let error = + install_host(CodingAgent::ClaudeCode, &options, &runner, &setup_runner).unwrap_err(); + + assert!(error.contains("doctor claude-code")); + let layout = PluginLayout::new(CodingAgent::ClaudeCode, dir.path()); + assert!(!layout.marketplace_root.exists()); + assert!(!layout.generation_lock.exists()); +} + +#[test] +fn registration_failure_does_not_restore_plugin_setup_that_never_ran() { + let dir = tempdir().unwrap(); + let mut runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("claude", "/bin/claude"); + runner.failing_suffix = Some("claude-code-marketplace".into()); + let setup_runner = MockSetupRunner::default(); + let install_dir = dir.path().join("failure"); + + let error = install_host( + CodingAgent::ClaudeCode, + &options(&install_dir), + &runner, + &setup_runner, + ) + .unwrap_err(); + + assert!(error.contains("plugin marketplace add")); + assert!( + setup_runner.calls().is_empty(), + "setup rollback should not run before setup was attempted" + ); + assert!( + !PluginLayout::new(CodingAgent::ClaudeCode, &install_dir) + .marketplace_root + .exists() + ); +} + +#[test] +fn plugin_registration_failure_rolls_back_marketplace_without_plugin_removal() { + let dir = tempdir().unwrap(); + let mut runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("codex", "/bin/codex"); + runner.failing_suffix = Some("plugin add nemo-relay-plugin@nemo-relay-local".into()); + let setup_runner = MockSetupRunner::default(); + let layout = PluginLayout::new(CodingAgent::Codex, dir.path()); + + let error = install_host( + CodingAgent::Codex, + &options(dir.path()), + &runner, + &setup_runner, + ) + .unwrap_err(); + + assert!(error.contains("plugin add"), "{error}"); + assert!( + error.contains("nemo-relay-plugin@nemo-relay-local"), + "{error}" + ); + assert!(!layout.marketplace_root.exists()); + assert!(!layout.state_path.exists()); + assert!( + runner + .commands() + .iter() + .any(|command| command.ends_with("plugin marketplace remove nemo-relay-local")) + ); + assert!( + runner + .commands() + .iter() + .all(|command| !command.contains("plugin remove nemo-relay-plugin")) + ); + assert!(setup_runner.calls().is_empty()); +} + +#[test] +fn failed_marketplace_registration_rolls_back_observed_host_side_effects() { + let dir = tempdir().unwrap(); + let layout = PluginLayout::new(CodingAgent::Codex, dir.path()); + let mut runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("codex", "/bin/codex") + .with_codex_registration_sequence(&[(false, false), (false, true)]); + runner.failing_suffix = Some(layout.marketplace_root.display().to_string()); + let setup_runner = MockSetupRunner::default(); + + let error = install_host( + CodingAgent::Codex, + &options(dir.path()), + &runner, + &setup_runner, + ) + .unwrap_err(); + + assert!(error.contains("plugin marketplace add"), "{error}"); + assert!( + runner + .commands() + .iter() + .any(|command| command.ends_with("plugin marketplace remove nemo-relay-local")) + ); + assert!(!layout.marketplace_root.exists()); + assert!(!layout.state_path.exists()); + assert!(!layout.generation_lock.exists()); +} + +#[test] +fn failed_plugin_registration_rolls_back_observed_plugin_side_effects() { + let dir = tempdir().unwrap(); + let mut runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("codex", "/bin/codex") + .with_codex_registration_sequence(&[(false, false), (true, true)]); + runner.failing_suffix = Some("plugin add nemo-relay-plugin@nemo-relay-local".into()); + let setup_runner = MockSetupRunner::default(); + let layout = PluginLayout::new(CodingAgent::Codex, dir.path()); + + let error = install_host( + CodingAgent::Codex, + &options(dir.path()), + &runner, + &setup_runner, + ) + .unwrap_err(); + + assert!(error.contains("plugin add"), "{error}"); + assert!( + runner + .commands() + .iter() + .any(|command| command.ends_with("plugin remove nemo-relay-plugin@nemo-relay-local")) + ); + assert!(!layout.marketplace_root.exists()); + assert!(!layout.state_path.exists()); + assert!(!layout.generation_lock.exists()); +} + +#[test] +fn unverifiable_registration_failure_preserves_the_install_tree() { + let dir = tempdir().unwrap(); + let layout = PluginLayout::new(CodingAgent::Codex, dir.path()); + let mut runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("codex", "/bin/codex"); + runner.failing_suffix = Some(layout.marketplace_root.display().to_string()); + runner.capture_output_sequences.get_mut().insert( + "/bin/codex plugin list".into(), + VecDeque::from([ + CommandOutput::success(String::new()), + CommandOutput { + status: 1, + stdout: String::new(), + stderr: "registration report unavailable".into(), + }, + ]), + ); + runner.capture_output_sequences.get_mut().insert( + "/bin/codex plugin marketplace list".into(), + VecDeque::from([CommandOutput::success(String::new())]), + ); + let setup_runner = MockSetupRunner::default(); + + let error = install_host( + CodingAgent::Codex, + &options(dir.path()), + &runner, + &setup_runner, + ) + .unwrap_err(); + + assert!(error.contains("refusing destructive rollback"), "{error}"); + assert!(error.contains("registration report unavailable"), "{error}"); + assert!(layout.marketplace_root.exists()); + assert!(layout.state_path.exists()); + assert!(layout.generation_lock.exists()); + InstallGeneration::capture(layout.generation_fence).unwrap(); + assert!( + runner + .commands() + .iter() + .all(|command| !command.contains("plugin marketplace remove")) + ); +} + +#[test] +fn invalid_existing_state_fails_before_generating_marketplace() { + let dir = tempdir().unwrap(); + let runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("codex", "/bin/codex"); + let setup_runner = MockSetupRunner::default(); + let layout = PluginLayout::new(CodingAgent::Codex, dir.path()); + std::fs::create_dir_all(&layout.state_path).unwrap(); + + let error = install_host( + CodingAgent::Codex, + &options(dir.path()), + &runner, + &setup_runner, + ) + .unwrap_err(); + + assert!(error.contains("failed to snapshot"), "{error}"); + assert!(!layout.marketplace_root.exists()); + assert!(layout.state_path.exists()); + assert!(runner.commands().is_empty()); + assert!(setup_runner.calls().is_empty()); +} + +#[test] +fn retry_after_partial_registration_rollback_does_not_restore_uninstalled_setup() { + let dir = tempdir().unwrap(); + let mut runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("codex", "/bin/codex"); + runner.failing_suffixes = vec![ + "plugin add nemo-relay-plugin@nemo-relay-local".into(), + "plugin marketplace remove nemo-relay-local".into(), + ]; + let setup_runner = MockSetupRunner::default(); + + let error = install_host( + CodingAgent::Codex, + &options(dir.path()), + &runner, + &setup_runner, + ) + .unwrap_err(); + + assert!(error.contains("additionally failed to roll back install")); + let state = read_state(CodingAgent::Codex, dir.path()).unwrap(); + assert!(state.host_plugin_removed); + assert!(!state.host_marketplace_removed); + assert!(!state.plugin_setup_installed); + + let runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("codex", "/bin/codex"); + uninstall_host( + CodingAgent::Codex, + &options(dir.path()), + &runner, + &setup_runner, + ) + .unwrap(); + + assert_eq!( + setup_runner.calls(), + vec!["refresh gateway"], + "retry cleanup may stop the gateway but must not restore provider/hooks setup that install never reached" + ); +} + +#[test] +fn retry_after_failed_codex_setup_does_not_uninstall_restored_setup() { + let dir = tempdir().unwrap(); + let mut runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("codex", "/bin/codex"); + runner.failing_suffix = Some("plugin marketplace remove nemo-relay-local".into()); + let setup_runner = MockSetupRunner { + failing_call: Some(format!("setup codex {DEFAULT_GATEWAY_URL}")), + ..MockSetupRunner::default() + }; + + let error = install_host( + CodingAgent::Codex, + &options(dir.path()), + &runner, + &setup_runner, + ) + .unwrap_err(); + + assert!(error.contains("additionally failed to roll back install")); + let state = read_state(CodingAgent::Codex, dir.path()).unwrap(); + assert!(state.host_plugin_removed); + assert!(!state.host_marketplace_removed); + assert!(!state.plugin_setup_installed); + + let runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("codex", "/bin/codex"); + uninstall_host( + CodingAgent::Codex, + &options(dir.path()), + &runner, + &setup_runner, + ) + .unwrap(); + + assert!( + setup_runner + .calls() + .iter() + .all(|call| call != &format!("uninstall codex {DEFAULT_GATEWAY_URL}")) + ); +} + +#[test] +fn uninstall_uses_installed_state_and_removes_marketplace() { + let dir = tempdir().unwrap(); + let runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("codex", "/bin/codex"); + let setup_runner = MockSetupRunner::default(); + install_host( + CodingAgent::Codex, + &options(dir.path()), + &runner, + &setup_runner, + ) + .unwrap(); + let layout = PluginLayout::new(CodingAgent::Codex, dir.path()); + assert!(layout.marketplace_root.exists()); + + uninstall_host( + CodingAgent::Codex, + &options(dir.path()), + &runner, + &setup_runner, + ) + .unwrap(); + + assert!(!layout.marketplace_root.exists()); + assert!(!layout.state_path.exists()); + let setup_calls = setup_runner.calls(); + let refresh_index = setup_calls + .iter() + .position(|call| call == "refresh gateway") + .unwrap(); + let uninstall_index = setup_calls + .iter() + .position(|call| call == &format!("uninstall codex {DEFAULT_GATEWAY_URL}")) + .unwrap(); + assert!(refresh_index < uninstall_index); +} + +#[test] +fn uninstall_rejects_registered_legacy_plugin_without_generation_fence() { + let dir = tempdir().unwrap(); + let runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("codex", "/bin/codex") + .with_codex_registration(true, true); + let setup_runner = MockSetupRunner::default(); + write_installed_state(CodingAgent::Codex, dir.path()); + let layout = PluginLayout::new(CodingAgent::Codex, dir.path()); + std::fs::remove_file(&layout.generation_fence).unwrap(); + + let error = uninstall_host( + CodingAgent::Codex, + &options(dir.path()), + &runner, + &setup_runner, + ) + .unwrap_err(); + + assert!( + error.contains("MCP generation marker is missing"), + "{error}" + ); + assert!(error.contains("close all Codex clients"), "{error}"); + assert!(layout.marketplace_root.exists()); + assert!(layout.state_path.exists()); + assert!(runner.commands().is_empty()); + assert!(setup_runner.calls().is_empty()); +} + +#[test] +fn uninstall_rejects_unregistered_legacy_plugin_without_generation_fence() { + let dir = tempdir().unwrap(); + let runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("codex", "/bin/codex") + .with_codex_registration(false, false); + let setup_runner = MockSetupRunner::default(); + write_installed_state(CodingAgent::Codex, dir.path()); + let layout = PluginLayout::new(CodingAgent::Codex, dir.path()); + std::fs::remove_file(&layout.generation_fence).unwrap(); + + let error = uninstall_host( + CodingAgent::Codex, + &options(dir.path()), + &runner, + &setup_runner, + ) + .unwrap_err(); + + assert!( + error.contains("MCP generation marker is missing"), + "{error}" + ); + assert!(layout.marketplace_root.exists()); + assert!(layout.state_path.exists()); + assert!(runner.commands().is_empty()); + assert!(setup_runner.calls().is_empty()); +} + +#[test] +fn uninstall_rejects_each_corrupt_generation_marker_actionably() { + for (corruption, cause) in [ + ("empty", "is empty"), + ("oversized", "exceeds the 128-byte limit"), + ("unreadable", "failed to"), + ] { + let dir = tempdir().unwrap(); + let runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("codex", "/bin/codex") + .with_codex_registration(false, false); + let setup_runner = MockSetupRunner::default(); + write_installed_state(CodingAgent::Codex, dir.path()); + let layout = PluginLayout::new(CodingAgent::Codex, dir.path()); + corrupt_generation_fence(&layout.generation_fence, corruption); + + let error = uninstall_host( + CodingAgent::Codex, + &options(dir.path()), + &runner, + &setup_runner, + ) + .unwrap_err(); + + assert_actionable_generation_error(&error, "is invalid or unreadable"); + assert!(error.contains(cause), "{corruption}: {error}"); + assert!(layout.marketplace_root.exists()); + assert!(layout.state_path.exists()); + assert!(runner.commands().is_empty()); + assert!(setup_runner.calls().is_empty()); + } +} + +#[test] +fn uninstall_continues_when_relay_is_missing() { + let dir = tempdir().unwrap(); + let runner = MockRunner::default().with_executable("codex", "/bin/codex"); + let setup_runner = MockSetupRunner::default(); + let layout = PluginLayout::new(CodingAgent::Codex, dir.path()); + write_installed_state(CodingAgent::Codex, dir.path()); + + uninstall_host( + CodingAgent::Codex, + &options(dir.path()), + &runner, + &setup_runner, + ) + .unwrap(); + + assert!(!layout.marketplace_root.exists()); + assert!(!layout.state_path.exists()); + assert!( + setup_runner + .calls() + .iter() + .any(|call| call == &format!("uninstall codex {DEFAULT_GATEWAY_URL}")) + ); +} + +#[test] +fn doctor_json_uses_quiet_plugin_report() { + let dir = tempdir().unwrap(); + let runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("codex", "/bin/codex") + .with_capture_output( + "/bin/codex plugin list", + "PLUGIN STATUS VERSION PATH\n\ + nemo-relay-plugin@nemo-relay-local installed, enabled 0.4.0 /tmp/nemo-relay-plugin\n", + ) + .with_capture_output( + "/bin/codex plugin marketplace list", + "MARKETPLACE ROOT\nnemo-relay-local /tmp/nemo-relay-local\n", + ); + let setup_runner = MockSetupRunner::default(); + let options = options(dir.path()); + write_installed_state(CodingAgent::Codex, dir.path()); + + let report = + doctor_host_json_value(CodingAgent::Codex, &options, &runner, &setup_runner).unwrap(); + + assert_eq!( + setup_runner.calls(), + vec![format!("doctor-json codex {DEFAULT_GATEWAY_URL}")] + ); + assert_eq!(report["host"], json!("codex")); + assert_eq!(report["ok"], json!(true)); + assert_eq!(report["host_registration"]["ok"], json!(true)); + assert_eq!( + runner.capture_commands(), + vec![ + "/bin/codex --version", + "/bin/codex plugin list", + "/bin/codex plugin marketplace list" + ] + ); +} + +#[test] +fn doctor_uses_plugin_root_persisted_in_install_state() { + let dir = tempdir().unwrap(); + let runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("codex", "/bin/codex") + .with_codex_registration(true, true); + let setup_runner = MockSetupRunner::default(); + let install_options = options(dir.path()); + write_installed_state(CodingAgent::Codex, dir.path()); + let layout = PluginLayout::new(CodingAgent::Codex, dir.path()); + let relocated_root = dir.path().join("relocated-plugin-root"); + std::fs::rename(&layout.plugin_root, &relocated_root).unwrap(); + write_state_for_host( + CodingAgent::Codex, + &PluginState { + marketplace_root: layout.marketplace_root.clone(), + plugin_root: relocated_root.clone(), + host_plugin_removed: false, + host_marketplace_removed: false, + plugin_setup_installed: true, + }, + dir.path(), + &install_options, + ) + .unwrap(); + + let _readiness = + collect_host_plugin_readiness(CodingAgent::Codex, &install_options, &runner, &setup_runner); + + assert_eq!(setup_runner.doctor_roots(), vec![relocated_root]); +} + +#[test] +fn codex_doctor_reports_upgrade_remediation_for_old_and_malformed_versions() { + for (version_output, expected_detail) in [ + ("codex-cli 0.142.9\n", "requires codex-cli 0.143.0"), + ("codex nightly\n", "could not parse"), + ] { + let dir = tempdir().unwrap(); + let runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("codex", "/bin/codex") + .with_codex_registration(true, true) + .with_capture_output("/bin/codex --version", version_output); + let setup_runner = MockSetupRunner::default(); + let options = options(dir.path()); + write_installed_state(CodingAgent::Codex, dir.path()); + + let report = + doctor_host_json_value(CodingAgent::Codex, &options, &runner, &setup_runner).unwrap(); + let version_check = report["readiness_checks"] + .as_array() + .unwrap() + .iter() + .find(|check| check["name"] == "Codex version") + .unwrap(); + assert_eq!(report["ok"], json!(false)); + assert_eq!(version_check["ok"], json!(false)); + assert!( + version_check["details"] + .as_str() + .unwrap() + .contains(expected_detail) + ); + assert_eq!( + report["remediation"], + json!( + "upgrade to codex-cli 0.143.0 or newer, then run `nemo-relay install codex --force`" + ) + ); + + let text_error = + doctor_host(CodingAgent::Codex, &options, &runner, &setup_runner).unwrap_err(); + assert!(text_error.contains("remediation: upgrade to codex-cli")); + assert!(text_error.contains("codex-cli 0.143.0 or newer")); + } +} + +#[test] +fn claude_doctor_reports_upgrade_remediation_for_old_and_malformed_versions() { + for (version_output, expected_detail) in [ + ("2.1.120 (Claude Code)\n", "requires Claude Code 2.1.121"), + ("Claude Code nightly\n", "could not parse"), + ] { + let dir = tempdir().unwrap(); + let runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("claude", "/bin/claude") + .with_claude_registration(true, true) + .with_capture_output("/bin/claude --version", version_output); + let setup_runner = MockSetupRunner::default(); + let options = options(dir.path()); + write_installed_state(CodingAgent::ClaudeCode, dir.path()); + + let report = + doctor_host_json_value(CodingAgent::ClaudeCode, &options, &runner, &setup_runner) + .unwrap(); + let version_check = report["readiness_checks"] + .as_array() + .unwrap() + .iter() + .find(|check| check["name"] == "Claude Code version") + .unwrap(); + assert_eq!(report["ok"], json!(false)); + assert_eq!(version_check["ok"], json!(false)); + assert!( + version_check["details"] + .as_str() + .unwrap() + .contains(expected_detail) + ); + assert_eq!( + report["remediation"], + json!( + "upgrade to Claude Code 2.1.121 or newer, then run `nemo-relay install claude-code --force`" + ) + ); + + let text_error = + doctor_host(CodingAgent::ClaudeCode, &options, &runner, &setup_runner).unwrap_err(); + assert!(text_error.contains("remediation: upgrade to Claude Code")); + assert!(text_error.contains("upgrade to Claude Code 2.1.121 or newer")); + } +} + +#[test] +fn readiness_report_marks_missing_generated_plugin_files_as_failed() { + let dir = tempdir().unwrap(); + let runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("codex", "/bin/codex") + .with_capture_output( + "/bin/codex plugin list", + "nemo-relay-plugin@nemo-relay-local installed, enabled\n", + ) + .with_capture_output( + "/bin/codex plugin marketplace list", + "nemo-relay-local /tmp/nemo-relay-local\n", + ); + let setup_runner = MockSetupRunner::default(); + let options = options(dir.path()); + write_installed_state(CodingAgent::Codex, dir.path()); + let layout = PluginLayout::new(CodingAgent::Codex, dir.path()); + std::fs::remove_file(layout.plugin_manifest).unwrap(); + + let report = + collect_host_plugin_readiness(CodingAgent::Codex, &options, &runner, &setup_runner); + + assert!(!report.ok()); + assert!(report.checks.iter().any(|check| { + check.name == "Generated plugin" && !check.ok && check.details.contains("missing") + })); + assert_eq!( + setup_runner.calls(), + vec![format!("doctor-json codex {DEFAULT_GATEWAY_URL}")] + ); +} + +#[test] +fn readiness_report_rejects_missing_generated_mcp_server() { + let dir = tempdir().unwrap(); + let runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("codex", "/bin/codex"); + let setup_runner = MockSetupRunner::default(); + let options = options(dir.path()); + write_installed_state(CodingAgent::Codex, dir.path()); + let layout = PluginLayout::new(CodingAgent::Codex, dir.path()); + std::fs::remove_file(layout.mcp_config).unwrap(); + + let report = + collect_host_plugin_readiness(CodingAgent::Codex, &options, &runner, &setup_runner); + + assert!(!report.ok()); + assert!(report.checks.iter().any(|check| { + check.name == "Generated MCP server" && !check.ok && check.details.contains("missing") + })); +} + +#[test] +fn readiness_report_rejects_missing_mcp_generation_fence() { + let dir = tempdir().unwrap(); + let runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("codex", "/bin/codex"); + let setup_runner = MockSetupRunner::default(); + let options = options(dir.path()); + write_installed_state(CodingAgent::Codex, dir.path()); + let layout = PluginLayout::new(CodingAgent::Codex, dir.path()); + std::fs::remove_file(layout.generation_fence).unwrap(); + + let report = + collect_host_plugin_readiness(CodingAgent::Codex, &options, &runner, &setup_runner); + + assert!(!report.ok()); + assert!(report.checks.iter().any(|check| { + check.name == "MCP generation fence" + && !check.ok + && check.details.contains("failed to open") + })); +} + +#[test] +fn claude_readiness_requires_its_mcp_server_and_generation_fence() { + let dir = tempdir().unwrap(); + let runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("claude", "/bin/claude") + .with_claude_registration(true, true); + let setup_runner = MockSetupRunner::default(); + let options = options(dir.path()); + write_installed_state(CodingAgent::ClaudeCode, dir.path()); + let layout = PluginLayout::new(CodingAgent::ClaudeCode, dir.path()); + std::fs::remove_file(layout.mcp_config).unwrap(); + std::fs::remove_file(layout.generation_fence).unwrap(); + + let report = + collect_host_plugin_readiness(CodingAgent::ClaudeCode, &options, &runner, &setup_runner); + + assert!(!report.ok()); + for name in ["Generated MCP server", "MCP generation fence"] { + assert!( + report + .checks + .iter() + .any(|check| check.name == name && !check.ok), + "missing failed readiness check for {name}" + ); + } +} + +#[test] +fn readiness_report_rejects_mcp_server_for_different_binary() { + let dir = tempdir().unwrap(); + let runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("codex", "/bin/codex"); + let setup_runner = MockSetupRunner::default(); + let options = options(dir.path()); + write_installed_state(CodingAgent::Codex, dir.path()); + let layout = PluginLayout::new(CodingAgent::Codex, dir.path()); + let generation = InstallGeneration::capture(layout.generation_fence.clone()).unwrap(); + write_json( + &layout.mcp_config, + &plugin_mcp_config( + CodingAgent::Codex, + Path::new("/tmp/other-relay"), + &layout.generation_fence, + generation.token(), + ) + .unwrap(), + ) + .unwrap(); + + let report = + collect_host_plugin_readiness(CodingAgent::Codex, &options, &runner, &setup_runner); + + assert!(!report.ok()); + assert!(report.checks.iter().any(|check| { + check.name == "Generated MCP server" && !check.ok && check.details.contains("unexpected") + })); +} + +#[test] +fn readiness_report_rejects_a_stale_mcp_generation_identity() { + let dir = tempdir().unwrap(); + let runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("codex", "/bin/codex"); + let setup_runner = MockSetupRunner::default(); + let options = options(dir.path()); + write_installed_state(CodingAgent::Codex, dir.path()); + let layout = PluginLayout::new(CodingAgent::Codex, dir.path()); + let mut mcp = + serde_json::from_str::(&std::fs::read_to_string(&layout.mcp_config).unwrap()) + .unwrap(); + mcp["nemo-relay"]["env"]["NEMO_RELAY_MCP_GENERATION"] = json!("stale-generation"); + write_json(&layout.mcp_config, &mcp).unwrap(); + + let report = + collect_host_plugin_readiness(CodingAgent::Codex, &options, &runner, &setup_runner); + + assert!(!report.ok()); + let check = report + .checks + .iter() + .find(|check| check.name == "Generated MCP server") + .unwrap(); + assert!(!check.ok); + assert!(check.details.contains("unexpected MCP server manifest")); + assert!(check.details.contains("nemo-relay install codex --force")); +} + +#[test] +fn generated_codex_mcp_check_allows_previously_captured_environment_names() { + let dir = tempdir().unwrap(); + let path = dir.path().join(".mcp.json"); + let expected = json!({ + "nemo-relay": { + "command": "/bin/nemo-relay", + "args": ["mcp"], + "env_vars": ["OPENAI_API_KEY"] + } + }); + let mut installed = expected.clone(); + installed["nemo-relay"]["env_vars"] + .as_array_mut() + .unwrap() + .push(json!("NEMO_RELAY_PREVIOUSLY_DEFINED")); + write_json(&path, &installed).unwrap(); + + let result = generated_mcp_config_check(CodingAgent::Codex, &path, &expected); + + assert_eq!(result.unwrap(), format!("valid at {}", path.display())); +} + +#[test] +fn generated_codex_mcp_check_accepts_a_windows_allowlist_with_a_historical_name() { + let dir = tempdir().unwrap(); + let path = dir.path().join(".mcp.json"); + let expected_vars = + crate::mcp_environment::forwarded_names_for_platform(std::iter::empty(), None, true); + let expected = json!({ + "nemo-relay": { + "command": "C:\\Program Files\\NeMo Relay\\nemo-relay.exe", + "args": ["mcp"], + "env_vars": expected_vars + } + }); + let mut installed = expected.clone(); + installed["nemo-relay"]["env_vars"] + .as_array_mut() + .unwrap() + .push(json!("NEMO_RELAY_PREVIOUSLY_DEFINED")); + write_json(&path, &installed).unwrap(); + + let result = + generated_mcp_config_check_for_platform(CodingAgent::Codex, &path, &expected, true); + + assert_eq!(result.unwrap(), format!("valid at {}", path.display())); +} + +#[test] +fn generated_codex_mcp_check_rejects_malformed_or_unapproved_environment_supersets() { + let dir = tempdir().unwrap(); + let path = dir.path().join(".mcp.json"); + let expected = json!({ + "nemo-relay": { + "command": "/bin/nemo-relay", + "args": ["mcp"], + "env_vars": ["OPENAI_API_KEY"] + } + }); + + for invalid in [ + json!({"not": "a name"}), + json!("NEMO_RELAY_WORKER_TOKEN"), + json!("UNRELATED_SECRET"), + json!("OPENAI_API_KEY"), + ] { + let mut installed = expected.clone(); + installed["nemo-relay"]["env_vars"] + .as_array_mut() + .unwrap() + .push(invalid); + write_json(&path, &installed).unwrap(); + + let error = generated_mcp_config_check(CodingAgent::Codex, &path, &expected) + .expect_err("invalid environment superset passed doctor validation"); + assert!(error.contains("unexpected MCP server manifest"), "{error}"); + assert!( + error.contains("nemo-relay install codex --force"), + "{error}" + ); + } +} + +#[test] +fn generated_mcp_check_rejects_host_shape_and_non_environment_drift() { + let dir = tempdir().unwrap(); + let path = dir.path().join(".mcp.json"); + let expected = json!({ + "nemo-relay": { + "command": "/bin/nemo-relay", + "args": ["mcp"], + "env_vars": ["OPENAI_API_KEY"] + } + }); + + let mut wrong_command = expected.clone(); + wrong_command["nemo-relay"]["command"] = json!("/bin/foreign-relay"); + write_json(&path, &wrong_command).unwrap(); + let error = generated_mcp_config_check(CodingAgent::ClaudeCode, &path, &expected).unwrap_err(); + assert!(error.contains("install claude-code --force"), "{error}"); + + let expected_without_vars = json!({ + "nemo-relay": { + "command": "/bin/nemo-relay", + "args": ["mcp"] + } + }); + write_json(&path, &wrong_command).unwrap(); + let error = + generated_mcp_config_check(CodingAgent::Codex, &path, &expected_without_vars).unwrap_err(); + assert!(error.contains("unexpected MCP server manifest"), "{error}"); + + let mut actual_without_vars = expected.clone(); + actual_without_vars["nemo-relay"] + .as_object_mut() + .unwrap() + .remove("env_vars"); + write_json(&path, &actual_without_vars).unwrap(); + let error = generated_mcp_config_check(CodingAgent::Codex, &path, &expected).unwrap_err(); + assert!(error.contains("unexpected MCP server manifest"), "{error}"); + + write_json(&path, &wrong_command).unwrap(); + let error = generated_mcp_config_check(CodingAgent::Codex, &path, &expected).unwrap_err(); + assert!(error.contains("unexpected MCP server manifest"), "{error}"); + assert!(error.contains("install codex --force"), "{error}"); +} + +#[test] +fn legacy_claude_manifest_inspection_distinguishes_absent_unreadable_and_malformed_files() { + let dir = tempdir().unwrap(); + let plugin_root = dir.path().join("plugin"); + std::fs::create_dir_all(&plugin_root).unwrap(); + assert!(!legacy_plugin_without_mcp(CodingAgent::ClaudeCode, &plugin_root).unwrap()); + + let manifest = plugin_manifest_path(CodingAgent::ClaudeCode, &plugin_root); + std::fs::create_dir_all(&manifest).unwrap(); + let error = legacy_plugin_without_mcp(CodingAgent::ClaudeCode, &plugin_root).unwrap_err(); + assert!( + error.contains("failed to inspect legacy plugin manifest"), + "{error}" + ); + + std::fs::remove_dir(&manifest).unwrap(); + std::fs::write(&manifest, "{not-json").unwrap(); + let error = legacy_plugin_without_mcp(CodingAgent::ClaudeCode, &plugin_root).unwrap_err(); + assert!( + error.contains("failed to inspect legacy plugin manifest"), + "{error}" + ); + + std::fs::write(&manifest, r#"{"name":"legacy"}"#).unwrap(); + assert!(legacy_plugin_without_mcp(CodingAgent::ClaudeCode, &plugin_root).unwrap()); + std::fs::write(&manifest, r#"{"mcpServers":{}}"#).unwrap(); + assert!(!legacy_plugin_without_mcp(CodingAgent::ClaudeCode, &plugin_root).unwrap()); +} + +#[test] +fn readiness_report_names_newly_required_mcp_env_vars_and_force_remediation() { + let dir = tempdir().unwrap(); + let runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("codex", "/bin/codex"); + let setup_runner = MockSetupRunner::default(); + let options = options(dir.path()); + write_installed_state(CodingAgent::Codex, dir.path()); + let layout = PluginLayout::new(CodingAgent::Codex, dir.path()); + let mut mcp: Value = + serde_json::from_str(&std::fs::read_to_string(&layout.mcp_config).unwrap()).unwrap(); + mcp["nemo-relay"]["env_vars"] + .as_array_mut() + .unwrap() + .retain(|name| name != "OPENAI_API_KEY"); + write_json(&layout.mcp_config, &mcp).unwrap(); + + let report = + collect_host_plugin_readiness(CodingAgent::Codex, &options, &runner, &setup_runner); + + assert!(!report.ok()); + let check = report + .checks + .iter() + .find(|check| check.name == "Generated MCP server") + .unwrap(); + assert!(!check.ok); + assert!(check.details.contains("OPENAI_API_KEY")); + assert!(check.details.contains("nemo-relay install codex --force")); +} + +#[test] +fn readiness_report_rejects_invalid_generated_manifest_contents() { + let dir = tempdir().unwrap(); + let runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("codex", "/bin/codex") + .with_capture_output( + "/bin/codex plugin list", + "nemo-relay-plugin@nemo-relay-local installed, enabled\n", + ) + .with_capture_output( + "/bin/codex plugin marketplace list", + "nemo-relay-local /tmp/nemo-relay-local\n", + ); + let setup_runner = MockSetupRunner::default(); + let options = options(dir.path()); + write_installed_state(CodingAgent::Codex, dir.path()); + let layout = PluginLayout::new(CodingAgent::Codex, dir.path()); + std::fs::write( + &layout.marketplace_manifest, + r#"{"name":"wrong-marketplace"}"#, + ) + .unwrap(); + + let report = + collect_host_plugin_readiness(CodingAgent::Codex, &options, &runner, &setup_runner); + + assert!(!report.ok()); + assert!(report.checks.iter().any(|check| { + check.name == "Generated marketplace" && !check.ok && check.details.contains("unexpected") + })); +} + +#[test] +fn readiness_report_accepts_generated_plugin_manifest_from_an_older_version() { + let dir = tempdir().unwrap(); + let runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("codex", "/bin/codex") + .with_capture_output( + "/bin/codex plugin list", + "nemo-relay-plugin@nemo-relay-local installed, enabled\n", + ) + .with_capture_output( + "/bin/codex plugin marketplace list", + "nemo-relay-local /tmp/nemo-relay-local\n", + ); + let setup_runner = MockSetupRunner::default(); + let options = options(dir.path()); + write_installed_state(CodingAgent::Codex, dir.path()); + let layout = PluginLayout::new(CodingAgent::Codex, dir.path()); + let mut manifest = plugin_manifest(CodingAgent::Codex); + manifest["version"] = json!("0.0.0"); + std::fs::write( + &layout.plugin_manifest, + serde_json::to_vec(&manifest).unwrap(), + ) + .unwrap(); + + let report = + collect_host_plugin_readiness(CodingAgent::Codex, &options, &runner, &setup_runner); + + assert!(report.ok()); + assert!( + report + .checks + .iter() + .any(|check| check.name == "Generated plugin" && check.ok) + ); +} + +#[test] +fn doctor_json_preserves_unknown_host_registration_state() { + let dir = tempdir().unwrap(); + let setup_runner = MockSetupRunner::default(); + let options = options(dir.path()); + write_installed_state(CodingAgent::Codex, dir.path()); + + let report = doctor_host_json_value( + CodingAgent::Codex, + &options, + &MockRunner::default(), + &setup_runner, + ) + .unwrap(); + + assert_eq!(report["host_registration"]["ok"], json!(false)); + assert!(report["host_registration"]["host_plugin_registered"].is_null()); + assert!(report["host_registration"]["host_marketplace_registered"].is_null()); +} + +#[test] +fn timed_out_host_plugin_readiness_is_actionable() { + let state_path = PathBuf::from("/tmp/nemo-relay/codex.json"); + let (sender, receiver) = std::sync::mpsc::sync_channel(1); + let _sender = sender; + + let report = crate::agents::receive_integration_readiness_for_test( + CodingAgent::Codex, + state_path.clone(), + receiver, + Path::new("/tmp/nemo-relay"), + Duration::ZERO, + ); + + assert!(!report.ok()); + assert_eq!(report.state_path, state_path); + assert_eq!(report.remediation, "nemo-relay install codex --force"); + assert!( + report + .checks + .iter() + .any(|check| check.name == "Host readiness" && !check.ok) + ); +} + +#[test] +fn stopped_lazy_sidecar_does_not_fail_host_readiness() { + let mut readiness = HostPluginReadiness { + host: "codex".into(), + remediation: "nemo-relay install codex --force".into(), + state_path: PathBuf::from("/tmp/codex.json"), + marketplace: None, + plugin: None, + checks: vec![], + relay: None, + host_plugin_registered: None, + host_marketplace_registered: None, + plugin_setup: None, + }; + + append_plugin_setup_checks( + &mut readiness, + &json!({ + "sidecar_health": "not_running_mcp_start", + "checks": { + "plugin_binary": true, + "sidecar_running": false, + "codex_provider_alias": true, + "codex_hooks": true + } + }), + ); + + assert!(readiness.ok()); + assert!( + readiness + .checks + .iter() + .any(|check| check.name == "Sidecar health") + ); + assert!( + !readiness + .checks + .iter() + .any(|check| check.name == "sidecar running") + ); +} + +#[test] +fn doctor_validates_claude_host_registration_before_setup_doctor() { + let dir = tempdir().unwrap(); + let runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("claude", "/bin/claude") + .with_capture_output( + "/bin/claude plugin list --json", + json!([ + { "id": "nemo-relay-plugin@nemo-relay-local" } + ]) + .to_string(), + ) + .with_capture_output( + "/bin/claude plugin marketplace list --json", + json!([ + { "name": "nemo-relay-local" } + ]) + .to_string(), + ); + let setup_runner = MockSetupRunner::default(); + let options = options(dir.path()); + write_installed_state(CodingAgent::ClaudeCode, dir.path()); + + doctor_host(CodingAgent::ClaudeCode, &options, &runner, &setup_runner).unwrap(); + + assert_eq!( + setup_runner.calls(), + vec![format!("doctor-json claude-code {DEFAULT_GATEWAY_URL}")] + ); + assert_eq!( + runner.capture_commands(), + vec![ + "/bin/claude --version", + "/bin/claude plugin list --json", + "/bin/claude plugin marketplace list --json" + ] + ); +} + +#[test] +fn doctor_fails_when_claude_host_plugin_is_missing() { + let dir = tempdir().unwrap(); + let runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("claude", "/bin/claude") + .with_capture_output("/bin/claude plugin list --json", json!([]).to_string()) + .with_capture_output( + "/bin/claude plugin marketplace list --json", + json!([ + { "name": "nemo-relay-local" } + ]) + .to_string(), + ); + let setup_runner = MockSetupRunner::default(); + let options = options(dir.path()); + write_installed_state(CodingAgent::ClaudeCode, dir.path()); + + let error = doctor_host(CodingAgent::ClaudeCode, &options, &runner, &setup_runner).unwrap_err(); + + assert!(error.contains("nemo-relay install claude-code --force")); + assert_eq!( + setup_runner.calls(), + vec![format!("doctor-json claude-code {DEFAULT_GATEWAY_URL}")] + ); +} + +#[test] +fn doctor_fails_when_claude_host_marketplace_is_missing() { + let dir = tempdir().unwrap(); + let runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("claude", "/bin/claude") + .with_capture_output( + "/bin/claude plugin list --json", + json!([ + { "id": "nemo-relay-plugin@nemo-relay-local" } + ]) + .to_string(), + ) + .with_capture_output( + "/bin/claude plugin marketplace list --json", + json!([]).to_string(), + ); + let setup_runner = MockSetupRunner::default(); + let options = options(dir.path()); + write_installed_state(CodingAgent::ClaudeCode, dir.path()); + + let error = doctor_host(CodingAgent::ClaudeCode, &options, &runner, &setup_runner).unwrap_err(); + + assert!(error.contains("nemo-relay install claude-code --force")); + assert_eq!( + setup_runner.calls(), + vec![format!("doctor-json claude-code {DEFAULT_GATEWAY_URL}")] + ); +} + +#[test] +fn uninstall_cleans_up_plugin_setup_before_host_removal_failure() { + let dir = tempdir().unwrap(); + let mut runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("codex", "/bin/codex"); + runner.failing_suffix = Some("plugin remove nemo-relay-plugin@nemo-relay-local".into()); + let setup_runner = MockSetupRunner::default(); + + let error = uninstall_host( + CodingAgent::Codex, + &options(dir.path()), + &runner, + &setup_runner, + ) + .unwrap_err(); + + assert!(error.contains("plugin remove")); + assert_eq!( + setup_runner.calls(), + vec![ + "refresh gateway".to_string(), + format!("uninstall codex {DEFAULT_GATEWAY_URL}"), + ] + ); +} + +#[test] +fn force_install_recovers_from_a_generation_retired_by_partial_uninstall() { + let dir = tempdir().unwrap(); + let mut failing_runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("codex", "/bin/codex") + .with_codex_registration(true, true); + failing_runner.failing_suffix = Some("plugin remove nemo-relay-plugin@nemo-relay-local".into()); + let setup_runner = MockSetupRunner::default(); + write_installed_state(CodingAgent::Codex, dir.path()); + let layout = PluginLayout::new(CodingAgent::Codex, dir.path()); + + let error = uninstall_host( + CodingAgent::Codex, + &options(dir.path()), + &failing_runner, + &setup_runner, + ) + .unwrap_err(); + assert!(error.contains("plugin remove"), "{error}"); + assert!( + std::fs::read_to_string(&layout.generation_fence) + .unwrap() + .starts_with("retired:") + ); + + let retry_runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("codex", "/bin/codex") + .with_codex_registration(true, true); + install_host( + CodingAgent::Codex, + &PluginInstallOptions { + force: true, + ..options(dir.path()) + }, + &retry_runner, + &setup_runner, + ) + .unwrap(); + + InstallGeneration::capture(layout.generation_fence).unwrap(); + assert!(layout.generation_lock.exists()); +} + +#[test] +fn uninstall_does_not_unregister_host_when_plugin_cleanup_fails() { + let dir = tempdir().unwrap(); + let runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("codex", "/bin/codex"); + let setup_runner = MockSetupRunner { + failing_call: Some(format!("uninstall codex {DEFAULT_GATEWAY_URL}")), + ..MockSetupRunner::default() + }; + write_installed_state(CodingAgent::Codex, dir.path()); + + let error = uninstall_host( + CodingAgent::Codex, + &options(dir.path()), + &runner, + &setup_runner, + ) + .unwrap_err(); + + assert!(error.contains("uninstall codex")); + let state = read_state(CodingAgent::Codex, dir.path()).unwrap(); + assert!(!state.host_plugin_removed); + assert!(!state.host_marketplace_removed); + assert!(state.plugin_setup_installed); +} + +#[test] +fn uninstall_retry_skips_host_removal_after_prior_success() { + let dir = tempdir().unwrap(); + let mut runner = MockRunner::default().with_executable("nemo-relay", "/bin/nemo-relay"); + runner.failing_suffix = Some("plugin remove nemo-relay-plugin@nemo-relay-local".into()); + let setup_runner = MockSetupRunner::default(); + let layout = PluginLayout::new(CodingAgent::Codex, dir.path()); + write_state_for_host( + CodingAgent::Codex, + &PluginState { + marketplace_root: layout.marketplace_root.clone(), + plugin_root: layout.plugin_root.clone(), + host_plugin_removed: true, + host_marketplace_removed: true, + plugin_setup_installed: true, + }, + dir.path(), + &options(dir.path()), + ) + .unwrap(); + crate::installation::generation::write_new_generation(&layout.generation_fence).unwrap(); + + uninstall_host( + CodingAgent::Codex, + &options(dir.path()), + &runner, + &setup_runner, + ) + .unwrap(); + + assert!( + runner + .commands() + .iter() + .all(|command| !command.contains("plugin remove nemo-relay-plugin")) + ); + assert!( + setup_runner + .calls() + .iter() + .any(|call| call == &format!("uninstall codex {DEFAULT_GATEWAY_URL}")) + ); + assert!(!layout.state_path.exists()); +} + +#[test] +fn uninstall_retry_skips_plugin_removal_after_marketplace_failure() { + let dir = tempdir().unwrap(); + let mut runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("codex", "/bin/codex"); + runner.failing_suffix = Some("plugin marketplace remove nemo-relay-local".into()); + let setup_runner = MockSetupRunner::default(); + let layout = PluginLayout::new(CodingAgent::Codex, dir.path()); + write_state(&layout, &options(dir.path())).unwrap(); + crate::installation::generation::write_new_generation(&layout.generation_fence).unwrap(); + + let error = uninstall_host( + CodingAgent::Codex, + &options(dir.path()), + &runner, + &setup_runner, + ) + .unwrap_err(); + + assert!(error.contains("plugin marketplace remove")); + let state = read_state(CodingAgent::Codex, dir.path()).unwrap(); + assert!(state.host_plugin_removed); + assert!(!state.host_marketplace_removed); + + let runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("codex", "/bin/codex"); + uninstall_host( + CodingAgent::Codex, + &options(dir.path()), + &runner, + &setup_runner, + ) + .unwrap(); + + assert!( + runner + .commands() + .iter() + .all(|command| !command.contains("plugin remove nemo-relay-plugin")) + ); + assert!(!layout.state_path.exists()); +} diff --git a/crates/cli/tests/coverage/main_tests.rs b/crates/cli/tests/coverage/commands/main_tests.rs similarity index 78% rename from crates/cli/tests/coverage/main_tests.rs rename to crates/cli/tests/coverage/commands/main_tests.rs index 85d512952..de330957b 100644 --- a/crates/cli/tests/coverage/main_tests.rs +++ b/crates/cli/tests/coverage/commands/main_tests.rs @@ -4,13 +4,29 @@ use clap::Parser; use std::ffi::OsString; +use super::completions::CompletionsCommand; +use super::serve::ServerArgs; use super::*; -use crate::config::{ - CompletionsCommand, PluginsCommand, PluginsEditCommand, PluginsInspectCommand, - PluginsListCommand, PluginsSubcommand, PluginsValidateCommand, PricingSubcommand, - PricingValidateCommand, ServerArgs, +use crate::commands::model_pricing::{PricingSubcommand, PricingValidateCommand}; +use crate::commands::plugins::{ + PluginsCommand, PluginsEditCommand, PluginsInspectCommand, PluginsListCommand, + PluginsSubcommand, PluginsValidateCommand, }; +#[test] +fn bootstrap_shutdown_token_is_removed_before_runtime_startup() { + let _environment = crate::test_support::EnvScope::set(&[( + crate::bootstrap::state::BOOTSTRAP_SHUTDOWN_TOKEN_ENV, + Some(std::ffi::OsStr::new("private-token")), + )]); + + assert_eq!( + crate::take_bootstrap_shutdown_token().as_deref(), + Some("private-token") + ); + assert!(std::env::var_os(crate::bootstrap::state::BOOTSTRAP_SHUTDOWN_TOKEN_ENV).is_none()); +} + struct EnvScope { _cwd_guard: Option, _guard: std::sync::MutexGuard<'static, ()>, @@ -83,6 +99,44 @@ fn completions_helper_reports_missing_shell_and_generates_requested_shell() { assert!(script.contains("_nemo-relay")); } +#[test] +fn cli_parses_native_mcp_subcommand_and_bind_override() { + let cli = Cli::try_parse_from(["nemo-relay", "mcp"]).unwrap(); + assert!(matches!(cli.command, Some(Command::Mcp))); + assert!(cli.server.bind.is_none()); + + let cli = Cli::try_parse_from(["nemo-relay", "--bind", "127.0.0.1:4041", "mcp"]).unwrap(); + assert!(matches!(cli.command, Some(Command::Mcp))); + assert_eq!(cli.server.bind.unwrap().to_string(), "127.0.0.1:4041"); + + assert!(Cli::try_parse_from(["nemo-relay", "mcp", "--agent", "codex"]).is_err()); +} + +#[test] +fn doctor_rejects_conflicting_agent_and_plugin_targets() { + let error = + Cli::try_parse_from(["nemo-relay", "doctor", "codex", "--plugin", "all"]).unwrap_err(); + assert!(error.to_string().contains("cannot be used with")); +} + +#[test] +fn multi_agent_operations_attempt_every_target_before_reporting_errors() { + let visited = std::cell::RefCell::new(Vec::new()); + let error = install::run_agent_operations(CodingAgent::ALL.to_vec(), "install", |agent| { + visited.borrow_mut().push(agent); + match agent { + CodingAgent::Codex => Err(error::CliError::Install("codex failure".into())), + CodingAgent::ClaudeCode => Ok(ExitCode::FAILURE), + CodingAgent::Hermes => Ok(ExitCode::SUCCESS), + } + }) + .unwrap_err() + .to_string(); + + assert_eq!(*visited.borrow(), CodingAgent::ALL); + assert!(error.contains("codex failure"), "{error}"); +} + #[test] fn safe_dispatch_helpers_cover_completions_and_plugins_paths() { let temp = tempfile::tempdir().unwrap(); @@ -209,20 +263,6 @@ fn safe_dispatch_plugin_json_errors_return_exit_codes() { #[tokio::test] async fn run_command_dispatches_safe_plugin_and_install_paths() { - let cli = Cli::try_parse_from(["nemo-relay", "plugin-shim", "install", "hermes"]).unwrap(); - let error = run_command(cli.command.unwrap(), &cli.server) - .await - .unwrap_err() - .to_string(); - assert!(error.contains("plugin install supports codex")); - - let cli = Cli::try_parse_from(["nemo-relay", "plugin-shim", "uninstall", "hermes"]).unwrap(); - let error = run_command(cli.command.unwrap(), &cli.server) - .await - .unwrap_err() - .to_string(); - assert!(error.contains("plugin uninstall supports codex")); - let dir = tempfile::tempdir().unwrap(); let install_dir = dir.path().join("plugin-install"); let install_dir_arg = install_dir.to_string_lossy().to_string(); diff --git a/crates/cli/tests/coverage/model_pricing_tests.rs b/crates/cli/tests/coverage/commands/model_pricing_tests.rs similarity index 81% rename from crates/cli/tests/coverage/model_pricing_tests.rs rename to crates/cli/tests/coverage/commands/model_pricing_tests.rs index c4c5ff965..70174d5e0 100644 --- a/crates/cli/tests/coverage/model_pricing_tests.rs +++ b/crates/cli/tests/coverage/commands/model_pricing_tests.rs @@ -34,34 +34,22 @@ fn catalog() -> PricingCatalog { #[test] fn pricing_helpers_cover_scopes_components_sources_and_usage() { assert_eq!( - target_pricing_scope(&PricingScopeArgs::default()).unwrap(), + target_pricing_scope(&ConfigurationScope::default()).unwrap(), TargetScope::User ); assert_eq!( - target_pricing_scope(&PricingScopeArgs { - project: true, - ..PricingScopeArgs::default() - }) - .unwrap(), + target_pricing_scope(&ConfigurationScope::Project).unwrap(), TargetScope::Project ); assert_eq!( - target_pricing_scope(&PricingScopeArgs { - global: true, - ..PricingScopeArgs::default() - }) - .unwrap(), + target_pricing_scope(&ConfigurationScope::Global).unwrap(), TargetScope::Global ); assert!( - target_pricing_scope(&PricingScopeArgs { - user: true, - project: true, - ..PricingScopeArgs::default() - }) - .unwrap_err() - .to_string() - .contains("choose only one") + target_pricing_scope(&ConfigurationScope::Invalid) + .unwrap_err() + .to_string() + .contains("choose only one") ); let mut plugin_config = PluginConfig::default(); @@ -115,6 +103,22 @@ fn pricing_component_rejects_malformed_component_config() { assert!(error.contains("invalid model pricing config")); } +#[test] +fn pricing_catalog_reads_are_bounded_and_require_utf8() { + let temp = tempfile::tempdir().unwrap(); + let invalid_utf8 = temp.path().join("invalid.json"); + std::fs::write(&invalid_utf8, [0xff]).unwrap(); + let error = read_pricing_catalog(&invalid_utf8).unwrap_err().to_string(); + assert!(error.contains("not valid UTF-8"), "{error}"); + + let oversized = temp.path().join("oversized.json"); + let file = std::fs::File::create(&oversized).unwrap(); + file.set_len(crate::filesystem::bounded::MAX_BOUNDED_FILE_BYTES + 1) + .unwrap(); + let error = read_pricing_catalog(&oversized).unwrap_err().to_string(); + assert!(error.contains("exceeds"), "{error}"); +} + #[test] fn pricing_document_update_preserves_dynamic_and_host_sections() { let temp = tempfile::tempdir().unwrap(); diff --git a/crates/cli/tests/coverage/installer_tests.rs b/crates/cli/tests/coverage/installer_tests.rs deleted file mode 100644 index 5d3020176..000000000 --- a/crates/cli/tests/coverage/installer_tests.rs +++ /dev/null @@ -1,268 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -use super::*; - -#[test] -fn hermes_config_merge_preserves_existing_yaml() { - let existing = r#" -model: - provider: auto -hooks: - pre_tool_call: - - command: ~/.hermes/agent-hooks/audit.sh -"#; - let merged = - merge_hermes_config(existing, hermes_hooks("nemo-relay hook-forward hermes")).unwrap(); - let yaml: Value = serde_yaml::from_str(&merged).unwrap(); - - assert_eq!(yaml["model"]["provider"], json!("auto")); - assert_eq!(yaml["hooks"]["pre_tool_call"].as_array().unwrap().len(), 2); - assert_eq!( - yaml["hooks"]["on_session_finalize"] - .as_array() - .unwrap() - .len(), - 1 - ); -} - -#[test] -fn hermes_config_merge_rejects_invalid_yaml() { - let error = merge_hermes_config( - "hooks: [not valid", - hermes_hooks("nemo-relay hook-forward hermes"), - ) - .unwrap_err() - .to_string(); - - assert!(error.contains("invalid YAML in Hermes config")); -} - -#[test] -fn hermes_hook_forward_prefers_dynamic_env_url() { - assert_eq!( - resolve_hook_gateway_url( - CodingAgent::Hermes, - Some("http://installed".into()), - Some("http://dynamic".into()), - ) - .as_deref(), - Some("http://dynamic") - ); - assert_eq!( - resolve_hook_gateway_url(CodingAgent::Hermes, Some("http://installed".into()), None,) - .as_deref(), - Some("http://installed") - ); - assert_eq!( - resolve_hook_gateway_url( - CodingAgent::Codex, - Some("http://installed".into()), - Some("http://dynamic".into()), - ) - .as_deref(), - Some("http://installed") - ); -} - -#[test] -fn merge_hooks_is_idempotent_and_preserves_existing_entries() { - let existing = json!({ - "hooks": { - "Stop": [{ "hooks": [{ "type": "command", "command": "existing" }] }] - } - }); - let generated = codex_hooks("nemo-relay hook-forward codex"); - let once = merge_hooks(existing, generated.clone()).unwrap(); - let twice = merge_hooks(once.clone(), generated).unwrap(); - assert_eq!(once, twice); - assert_eq!(twice["hooks"]["Stop"].as_array().unwrap().len(), 2); - assert_eq!( - twice["hooks"]["UserPromptExpansion"] - .as_array() - .unwrap() - .len(), - 1 - ); -} - -#[test] -fn merge_hooks_rejects_malformed_shapes() { - assert!(merge_hooks(json!([]), codex_hooks("cmd")).is_err()); - assert!(merge_hooks(json!({ "hooks": [] }), codex_hooks("cmd")).is_err()); - assert!(merge_hooks(json!({ "hooks": { "Stop": {} } }), codex_hooks("cmd")).is_err()); - assert!(merge_hooks(json!({}), json!({ "hooks": [] })).is_err()); -} - -#[test] -fn helper_formatting_and_headers_cover_optional_paths() { - assert!(event_matches_tools("PermissionRequest")); - assert!(!event_matches_tools("SessionStart")); - - let headers = gateway_headers( - Some("profile"), - Some(r#"{"team":"obs"}"#), - Some(GatewayMode::Passthrough), - ) - .unwrap(); - assert_eq!( - headers - .get("x-nemo-relay-gateway-mode") - .and_then(|value| value.to_str().ok()), - Some("passthrough") - ); - assert!( - insert_header( - &mut HeaderMap::new(), - "x-nemo-relay-config-profile", - Some("bad\nvalue") - ) - .is_err() - ); - - let headers = gateway_headers(None, None, None).unwrap(); - assert!(headers.is_empty()); -} - -#[test] -fn generated_hook_dispatch_covers_all_agents() { - for agent in [ - CodingAgent::ClaudeCode, - CodingAgent::Codex, - CodingAgent::Hermes, - ] { - assert!(generated_hooks(agent, "cmd")["hooks"].is_object()); - } - assert_eq!( - hook_forward_command("nemo-relay", CodingAgent::Hermes), - "nemo-relay hook-forward hermes" - ); - assert_eq!( - hook_forward_command("/abs/path/to/nemo-relay", CodingAgent::Codex), - "/abs/path/to/nemo-relay hook-forward codex" - ); -} - -#[test] -fn packaged_hook_configs_are_valid_json() { - let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("../../integrations/coding-agents"); - for path in [ - root.join("../../.agents/plugins/marketplace.json"), - root.join("../../.claude-plugin/marketplace.json"), - root.join("claude-code/hooks/hooks.json"), - root.join("codex/hooks/hooks.json"), - root.join("claude-code/.claude-plugin/plugin.json"), - root.join("codex/.codex-plugin/plugin.json"), - ] { - let raw = std::fs::read_to_string(&path).unwrap(); - serde_json::from_str::(&raw) - .unwrap_or_else(|error| panic!("{} is invalid JSON: {error}", path.display())); - } -} - -#[test] -fn packaged_plugin_hooks_use_expected_shim_commands() { - let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("../../integrations/coding-agents"); - let claude = serde_json::from_str::( - &std::fs::read_to_string(root.join("claude-code/hooks/hooks.json")).unwrap(), - ) - .unwrap(); - let codex = serde_json::from_str::( - &std::fs::read_to_string(root.join("codex/hooks/hooks.json")).unwrap(), - ) - .unwrap(); - - assert_eq!( - claude["hooks"]["SessionStart"][0]["hooks"][0]["command"], - json!("nemo-relay plugin-shim hook claude") - ); - assert_eq!( - codex["hooks"]["SessionStart"][0]["hooks"][0]["command"], - json!("nemo-relay plugin-shim hook codex") - ); - assert!( - claude["hooks"] - .as_object() - .unwrap() - .values() - .flat_map(|groups| groups.as_array().unwrap()) - .flat_map(|group| group["hooks"].as_array().unwrap()) - .all(|hook| hook["command"] - .as_str() - .is_some_and(|command| command.starts_with("nemo-relay "))) - ); - assert!( - codex["hooks"] - .as_object() - .unwrap() - .values() - .flat_map(|groups| groups.as_array().unwrap()) - .flat_map(|group| group["hooks"].as_array().unwrap()) - .all(|hook| hook["command"] - .as_str() - .is_some_and(|command| command.starts_with("nemo-relay "))) - ); -} - -#[test] -fn packaged_plugin_manifests_use_stable_plugin_name_and_version() { - let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("../../integrations/coding-agents"); - let claude_path = root.join("claude-code/.claude-plugin/plugin.json"); - let claude = - serde_json::from_str::(&std::fs::read_to_string(&claude_path).unwrap()).unwrap(); - assert_eq!(claude["name"], json!("nemo-relay-plugin")); - assert_eq!(claude["version"], json!(env!("CARGO_PKG_VERSION"))); - assert!(claude.get("hooks").is_none()); - - let codex_path = root.join("codex/.codex-plugin/plugin.json"); - let codex = - serde_json::from_str::(&std::fs::read_to_string(&codex_path).unwrap()).unwrap(); - assert_eq!(codex["name"], json!("nemo-relay-plugin")); - assert_eq!(codex["version"], json!(env!("CARGO_PKG_VERSION"))); - - let codex_marketplace_path = root.join("../../.agents/plugins/marketplace.json"); - let codex_marketplace = - serde_json::from_str::(&std::fs::read_to_string(&codex_marketplace_path).unwrap()) - .unwrap(); - assert_eq!(codex_marketplace["name"], json!("nemo-relay")); - assert_eq!( - codex_marketplace["plugins"][0]["name"], - json!("nemo-relay-plugin") - ); - assert_eq!( - codex_marketplace["plugins"][0]["source"]["path"], - json!("./integrations/coding-agents/codex") - ); - - let claude_marketplace_path = root.join("../../.claude-plugin/marketplace.json"); - let claude_marketplace = - serde_json::from_str::(&std::fs::read_to_string(&claude_marketplace_path).unwrap()) - .unwrap(); - assert_eq!(claude_marketplace["name"], json!("nemo-relay")); - assert_eq!( - claude_marketplace["plugins"][0]["name"], - json!("nemo-relay-plugin") - ); - assert_eq!( - claude_marketplace["plugins"][0]["source"], - json!("./integrations/coding-agents/claude-code") - ); -} - -#[test] -fn packaged_plugin_helpers_are_present() { - let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("../../integrations/coding-agents"); - for path in [ - root.join("claude-code/hooks/hooks.json"), - root.join("codex/hooks/hooks.json"), - ] { - let metadata = std::fs::metadata(&path) - .unwrap_or_else(|error| panic!("{} missing: {error}", path.display())); - assert!(metadata.is_file(), "{} is not a file", path.display()); - } -} diff --git a/crates/cli/tests/coverage/launcher_tests.rs b/crates/cli/tests/coverage/launcher_tests.rs deleted file mode 100644 index 660ca0268..000000000 --- a/crates/cli/tests/coverage/launcher_tests.rs +++ /dev/null @@ -1,1125 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -use super::*; -use crate::config::{AgentCommandConfig, GatewayConfig}; -use std::ffi::OsString; -use std::sync::Mutex; - -fn current_dir_lock() -> &'static Mutex<()> { - &crate::test_support::CWD_TEST_LOCK -} - -struct EnvScope { - _guard: std::sync::MutexGuard<'static, ()>, - values: Vec<(&'static str, Option)>, -} - -impl EnvScope { - fn set(values: &[(&'static str, Option<&std::ffi::OsStr>)]) -> Self { - let guard = crate::test_support::ENV_TEST_LOCK - .lock() - .unwrap_or_else(|error| error.into_inner()); - let previous = values - .iter() - .map(|(key, _)| (*key, std::env::var_os(key))) - .collect::>(); - for (key, value) in values { - unsafe { - match value { - Some(value) => std::env::set_var(key, value), - None => std::env::remove_var(key), - } - } - } - Self { - _guard: guard, - values: previous, - } - } -} - -impl Drop for EnvScope { - fn drop(&mut self) { - for (key, value) in self.values.drain(..) { - unsafe { - match value { - Some(value) => std::env::set_var(key, value), - None => std::env::remove_var(key), - } - } - } - } -} - -#[test] -fn infers_agent_from_command_or_uses_override() { - let command = RunCommand { - agent: None, - config: None, - openai_base_url: None, - anthropic_base_url: None, - session_metadata: None, - plugin_config_path: None, - dry_run: false, - print: false, - command: vec!["/usr/bin/codex".into()], - }; - let (agent, argv) = resolve_agent_and_argv(&command, &AgentConfigs::default()).unwrap(); - assert_eq!(agent, CodingAgent::Codex); - assert_eq!(argv, vec!["/usr/bin/codex"]); - - let command = RunCommand { - agent: Some(CodingAgent::ClaudeCode), - command: vec!["wrapper".into()], - ..command - }; - let (agent, _) = resolve_agent_and_argv(&command, &AgentConfigs::default()).unwrap(); - assert_eq!(agent, CodingAgent::ClaudeCode); -} - -#[test] -fn uses_configured_command_when_no_argv_is_supplied() { - let agents = AgentConfigs { - codex: AgentCommandConfig { - command: Some("codex --full-auto".into()), - hooks_path: None, - }, - ..AgentConfigs::default() - }; - let command = RunCommand { - agent: Some(CodingAgent::Codex), - config: None, - openai_base_url: None, - anthropic_base_url: None, - session_metadata: None, - plugin_config_path: None, - dry_run: false, - print: false, - command: vec![], - }; - - let (agent, argv) = resolve_agent_and_argv(&command, &agents).unwrap(); - - assert_eq!(agent, CodingAgent::Codex); - assert_eq!(argv, vec!["codex", "--full-auto"]); -} - -#[test] -fn uses_configured_hermes_command_when_no_argv_is_supplied() { - let agents = AgentConfigs { - hermes: AgentCommandConfig { - command: Some("hermes --yolo chat".into()), - hooks_path: None, - }, - ..AgentConfigs::default() - }; - let command = RunCommand { - agent: Some(CodingAgent::Hermes), - config: None, - openai_base_url: None, - anthropic_base_url: None, - session_metadata: None, - plugin_config_path: None, - dry_run: false, - print: false, - command: vec![], - }; - - let (agent, argv) = resolve_agent_and_argv(&command, &agents).unwrap(); - - assert_eq!(agent, CodingAgent::Hermes); - assert_eq!(argv, vec!["hermes", "--yolo", "chat"]); -} - -#[test] -fn inference_failure_has_actionable_message() { - let command = RunCommand { - agent: None, - config: None, - openai_base_url: None, - anthropic_base_url: None, - session_metadata: None, - plugin_config_path: None, - dry_run: false, - print: false, - command: vec!["my-agent".into()], - }; - - let error = resolve_agent_and_argv(&command, &AgentConfigs::default()) - .unwrap_err() - .to_string(); - - assert!(error.contains("pass --agent claude")); -} - -#[test] -fn missing_command_without_agent_errors() { - // Bare `nemo-relay run` (no command, no --agent) errors — we have nothing to spawn and no - // argv[0] to infer an agent from. With --agent set, we fall back to the agent's default - // binary name (e.g., `hermes`), so that branch is exercised in the resolution test - // below rather than here. - let command = RunCommand { - agent: None, - config: None, - openai_base_url: None, - anthropic_base_url: None, - session_metadata: None, - plugin_config_path: None, - dry_run: false, - print: false, - command: vec![], - }; - - let error = resolve_agent_and_argv(&command, &AgentConfigs::default()) - .unwrap_err() - .to_string(); - - assert!(error.contains("missing command")); -} - -#[test] -fn agent_without_configured_command_falls_back_to_default_binary() { - // `--agent hermes` with no `[agents.hermes] command = "..."` override resolves to the - // default executable name on $PATH. - let command = RunCommand { - agent: Some(CodingAgent::Hermes), - config: None, - openai_base_url: None, - anthropic_base_url: None, - session_metadata: None, - plugin_config_path: None, - dry_run: false, - print: false, - command: vec![], - }; - - let (agent, argv) = resolve_agent_and_argv(&command, &AgentConfigs::default()).unwrap(); - assert_eq!(agent, CodingAgent::Hermes); - assert_eq!(argv, vec!["hermes"]); -} - -#[test] -fn agent_with_passthrough_args_appends_to_configured_command() { - // The easy-path uses this code path: `nemo-relay codex -- --model X` resolves to the - // configured (or default) codex command with `--model X` appended. - let command = RunCommand { - agent: Some(CodingAgent::Codex), - config: None, - openai_base_url: None, - anthropic_base_url: None, - session_metadata: None, - plugin_config_path: None, - dry_run: false, - print: false, - command: vec!["--model".into(), "openai/openai/gpt-5.1-codex".into()], - }; - - let (_, argv) = resolve_agent_and_argv(&command, &AgentConfigs::default()).unwrap(); - assert_eq!( - argv, - vec!["codex", "--model", "openai/openai/gpt-5.1-codex"] - ); -} - -#[test] -fn default_and_configured_command_helpers_cover_empty_and_all_agents() { - assert_eq!(default_command_for(CodingAgent::ClaudeCode), "claude"); - assert_eq!(default_command_for(CodingAgent::Codex), "codex"); - assert_eq!(default_command_for(CodingAgent::Hermes), "hermes"); - - let agents = AgentConfigs { - codex: AgentCommandConfig { - command: Some(" ".into()), - hooks_path: None, - }, - ..AgentConfigs::default() - }; - assert!(configured_command(CodingAgent::Codex, &agents).is_none()); -} - -#[test] -fn prepares_codex_config_overrides() { - let resolved = ResolvedConfig { - gateway: GatewayConfig::default(), - agents: AgentConfigs::default(), - ..ResolvedConfig::default() - }; - let prepared = PreparedRun::new( - CodingAgent::Codex, - vec!["codex".into()], - "http://127.0.0.1:1234", - &resolved, - false, - ) - .unwrap(); - - assert!(prepared.argv.contains(&"features.hooks=true".into())); - assert!( - prepared - .argv - .iter() - .any(|arg| arg == "model_provider=\"nemo-relay-openai\"") - ); - assert!( - prepared - .argv - .iter() - .any(|arg| arg.contains("model_providers.nemo-relay-openai") - && arg.contains("base_url=\"http://127.0.0.1:1234\"") - // Codex sends its own credentials (ChatGPT-Plus OAuth or OPENAI_API_KEY). - // When OPENAI_API_KEY is in the environment the gateway substitutes it; - // otherwise codex's own auth is forwarded as-is. - && arg.contains("requires_openai_auth=true") - && arg.contains("supports_websockets=false")) - ); - assert!( - !prepared - .argv - .iter() - .any(|arg| arg.contains("model_providers.openai")) - ); - assert!( - prepared - .argv - .iter() - .any(|arg| arg.contains("hooks.SessionStart")) - ); - let path = prepared - .env - .iter() - .find_map(|(name, value)| (name == "PATH").then_some(value)) - .expect("transparent run should set PATH for hook subprocesses"); - let current_exe_dir = std::env::current_exe() - .unwrap() - .parent() - .unwrap() - .to_path_buf(); - let entries = std::env::split_paths(path).collect::>(); - assert!(entries.iter().any(|entry| entry == ¤t_exe_dir)); - if !std::env::var_os("PATH") - .as_deref() - .map(std::env::split_paths) - .into_iter() - .flatten() - .any(|entry| entry == current_exe_dir) - { - assert_eq!(entries.last(), Some(¤t_exe_dir)); - } -} - -#[test] -fn prepares_codex_with_hooks_when_auth_missing() { - let _guard = current_dir_lock().lock().unwrap(); - let temp = tempfile::tempdir().unwrap(); - let _env = EnvScope::set(&[ - ("OPENAI_API_KEY", None), - ("HOME", Some(temp.path().as_os_str())), - ("USERPROFILE", None), - ]); - let resolved = ResolvedConfig { - gateway: GatewayConfig::default(), - agents: AgentConfigs::default(), - ..ResolvedConfig::default() - }; - - let prepared = PreparedRun::new( - CodingAgent::Codex, - vec!["codex".into()], - "http://127.0.0.1:1234", - &resolved, - false, - ) - .unwrap(); - - assert!(prepared.argv.iter().any(|arg| arg == "features.hooks=true")); -} - -#[test] -fn exporter_destinations_describe_observability_outputs() { - let gateway = GatewayConfig { - plugin_config: Some(json!({ - "version": 1, - "components": [{ - "kind": OBSERVABILITY_PLUGIN_KIND, - "enabled": true, - "config": { - "version": 2, - "atof": { - "enabled": true, - "sinks": [{ - "type": "file", - "output_directory": "logs", - "filename": "events.jsonl" - }] - }, - "atif": { - "enabled": true, - "output_directory": "trajectories", - "filename_template": "agent-{session_id}.json" - }, - "opentelemetry": { - "enabled": true, - "endpoint": "http://127.0.0.1:4318/v1/traces" - }, - "openinference": { - "enabled": true - } - } - }] - })), - ..GatewayConfig::default() - }; - - let destinations = exporter_destinations(&gateway); - - assert!(destinations.iter().any(|line| line - == &format!( - "ATOF {}", - PathBuf::from("logs").join("events.jsonl").display() - ))); - assert!(destinations.iter().any(|line| line - == &format!( - "ATIF {}", - PathBuf::from("trajectories") - .join("agent-{session_id}.json") - .display() - ))); - assert!( - destinations - .iter() - .any(|line| line == "OpenTelemetry http://127.0.0.1:4318/v1/traces") - ); - assert!( - destinations - .iter() - .any(|line| line == "OpenInference OTLP endpoint from environment/default") - ); -} - -#[test] -fn exporter_destinations_describe_atif_remote_storage_instead_of_local_path() { - let gateway = GatewayConfig { - plugin_config: Some(json!({ - "version": 1, - "components": [{ - "kind": OBSERVABILITY_PLUGIN_KIND, - "enabled": true, - "config": { - "version": 1, - "atif": { - "enabled": true, - "output_directory": "trajectories", - "filename_template": "agent-{session_id}.json", - "storage": [ - {"type": "s3", "bucket": "traj-bucket", "key_prefix": "runs/"}, - {"type": "http", "endpoint": "https://collector.example/ingest"} - ] - } - } - }] - })), - ..GatewayConfig::default() - }; - - let destinations = exporter_destinations(&gateway); - - assert!( - destinations - .iter() - .any(|line| line == "ATIF s3://traj-bucket/runs") - ); - assert!( - destinations - .iter() - .any(|line| line == "ATIF https://collector.example/ingest") - ); - // The local path is skipped at runtime when storage is configured, so it must not be reported. - assert!( - !destinations - .iter() - .any(|line| line.contains("agent-{session_id}.json")) - ); -} - -#[test] -fn exporter_destinations_cover_invalid_disabled_and_missing_plugin_configs() { - let invalid_plugin = GatewayConfig { - plugin_config: Some(json!({"components": "not-a-list"})), - ..GatewayConfig::default() - }; - assert_eq!( - exporter_destinations(&invalid_plugin), - vec!["configured (invalid plugin config)".to_string()] - ); - - let disabled_observability = GatewayConfig { - plugin_config: Some(json!({ - "version": 1, - "components": [{ - "kind": OBSERVABILITY_PLUGIN_KIND, - "enabled": false, - "config": {"version": 1} - }] - })), - ..GatewayConfig::default() - }; - assert!(exporter_destinations(&disabled_observability).is_empty()); - - let invalid_observability = GatewayConfig { - plugin_config: Some(json!({ - "version": 1, - "components": [{ - "kind": OBSERVABILITY_PLUGIN_KIND, - "enabled": true, - "config": {"version": "bad"} - }] - })), - ..GatewayConfig::default() - }; - assert_eq!( - exporter_destinations(&invalid_observability), - vec!["Observability configured (invalid config)".to_string()] - ); - - assert!(exporter_destinations(&GatewayConfig::default()).is_empty()); -} - -#[test] -fn insert_after_agent_uses_last_matching_agent_or_first_word_fallback() { - let mut argv = vec![ - "wrapper".to_string(), - "codex".to_string(), - "subcommand".to_string(), - "/usr/local/bin/codex".to_string(), - ]; - insert_after_agent(&mut argv, CodingAgent::Codex, ["--config".to_string()]); - assert_eq!( - argv, - vec![ - "wrapper", - "codex", - "subcommand", - "/usr/local/bin/codex", - "--config" - ] - ); - - let mut wrapped = vec!["agent-wrapper".to_string(), "run".to_string()]; - insert_after_agent(&mut wrapped, CodingAgent::Hermes, ["--hook".to_string()]); - assert_eq!(wrapped, vec!["agent-wrapper", "--hook", "run"]); -} - -#[test] -fn prepares_claude_dry_run_without_writing_plugin() { - let resolved = ResolvedConfig { - gateway: GatewayConfig::default(), - agents: AgentConfigs::default(), - ..ResolvedConfig::default() - }; - let prepared = PreparedRun::new( - CodingAgent::ClaudeCode, - vec!["claude".into()], - "http://127.0.0.1:1234", - &resolved, - true, - ) - .unwrap(); - - assert_eq!(prepared.argv[1], "--plugin-dir"); - assert_eq!(prepared.argv[2], ""); - assert!( - prepared - .env - .contains(&("ANTHROPIC_BASE_URL".into(), "http://127.0.0.1:1234".into())) - ); - assert!(prepared.notes[0].contains("would generate")); -} - -#[test] -fn prepares_claude_dry_inserts_plugin_dir_after_last_agent_executable() { - let resolved = ResolvedConfig { - gateway: GatewayConfig::default(), - agents: AgentConfigs::default(), - ..ResolvedConfig::default() - }; - let prepared = PreparedRun::new( - CodingAgent::ClaudeCode, - vec![ - "wrapper".into(), - "claude".into(), - "subcommand".into(), - "/opt/bin/claude".into(), - "--resume".into(), - ], - "http://127.0.0.1:1234", - &resolved, - true, - ) - .unwrap(); - - let plugin_index = prepared - .argv - .iter() - .position(|arg| arg == "--plugin-dir") - .expect("plugin dir arg"); - assert_eq!(prepared.argv[plugin_index - 1], "/opt/bin/claude"); - assert_eq!( - prepared.argv[plugin_index + 1], - "" - ); - assert_eq!(prepared.argv.last().map(String::as_str), Some("--resume")); - assert!(prepared.temp_dirs.is_empty()); -} - -#[test] -fn prepares_hermes_hook_environment() { - let _guard = current_dir_lock().lock().unwrap(); - let temp = tempfile::tempdir().unwrap(); - let previous = std::env::current_dir().unwrap(); - std::env::set_current_dir(temp.path()).unwrap(); - let hooks_path = temp.path().join("hermes-home/config.yaml"); - let resolved = ResolvedConfig { - gateway: GatewayConfig::default(), - agents: AgentConfigs { - hermes: AgentCommandConfig { - command: None, - hooks_path: Some(hooks_path.clone()), - }, - ..AgentConfigs::default() - }, - dynamic_plugins: Vec::new(), - ..ResolvedConfig::default() - }; - let prepared = PreparedRun::new( - CodingAgent::Hermes, - vec!["hermes".into(), "chat".into()], - "http://127.0.0.1:1234", - &resolved, - false, - ) - .unwrap(); - - assert_eq!(prepared.argv, vec!["hermes", "chat"]); - assert!(prepared.env.contains(&( - "NEMO_RELAY_GATEWAY_URL".into(), - "http://127.0.0.1:1234".into() - ))); - assert!( - prepared - .env - .contains(&("HERMES_ACCEPT_HOOKS".into(), "1".into())) - ); - assert!( - prepared - .env - .contains(&("OPENAI_BASE_URL".into(), "http://127.0.0.1:1234/v1".into())) - ); - assert_eq!( - prepared - .hermes_restore - .as_ref() - .map(|restore| &restore.path), - Some(&hooks_path) - ); - let hooks = std::fs::read_to_string(&hooks_path).unwrap(); - assert!(hooks.contains("hook-forward hermes")); - assert!(hooks.contains("provider: custom")); - assert!(hooks.contains("base_url: http://127.0.0.1:1234/v1")); - assert!(prepared.notes[0].contains("temporarily merged")); - - prepared.restore().unwrap(); - assert!(!hooks_path.exists()); - std::env::set_current_dir(previous).unwrap(); -} - -#[test] -fn hermes_openai_base_url_normalizes_trailing_slashes() { - assert_eq!( - hermes_openai_base_url("http://127.0.0.1:1234///"), - "http://127.0.0.1:1234/v1" - ); -} - -#[test] -fn prepares_hermes_dry_uses_home_path_without_writing_hooks() { - let _guard = current_dir_lock().lock().unwrap(); - let temp = tempfile::tempdir().unwrap(); - let _env = EnvScope::set(&[ - ("HERMES_HOME", None), - ("HOME", Some(temp.path().as_os_str())), - ("USERPROFILE", None), - ]); - let resolved = ResolvedConfig { - gateway: GatewayConfig::default(), - agents: AgentConfigs::default(), - ..ResolvedConfig::default() - }; - - let prepared = PreparedRun::new( - CodingAgent::Hermes, - vec!["hermes".into()], - "http://127.0.0.1:1234", - &resolved, - true, - ) - .unwrap(); - - let hook_path = temp.path().join(".hermes/config.yaml"); - assert!(prepared.notes[0].contains(".hermes")); - assert!(prepared.notes[0].contains("config.yaml")); - assert!( - prepared - .env - .contains(&("HERMES_ACCEPT_HOOKS".into(), "1".into())) - ); - assert!( - prepared - .env - .contains(&("OPENAI_BASE_URL".into(), "http://127.0.0.1:1234/v1".into())) - ); - assert!(!hook_path.exists()); -} - -#[test] -fn hermes_hooks_path_prefers_configured_then_env_then_home() { - let _guard = current_dir_lock().lock().unwrap(); - let temp = tempfile::tempdir().unwrap(); - let configured = temp.path().join("configured.yaml"); - assert_eq!(hermes_hooks_path(Some(&configured)).unwrap(), configured); - - let _env = EnvScope::set(&[ - ("HERMES_HOME", Some(temp.path().as_os_str())), - ("HOME", None), - ("USERPROFILE", None), - ]); - assert_eq!( - hermes_hooks_path(None).unwrap(), - temp.path().join("config.yaml") - ); - - drop(_env); - let _env = EnvScope::set(&[ - ("HERMES_HOME", None), - ("HOME", Some(temp.path().as_os_str())), - ("USERPROFILE", None), - ]); - assert_eq!( - hermes_hooks_path(None).unwrap(), - temp.path().join(".hermes/config.yaml") - ); - - drop(_env); - let _env = EnvScope::set(&[("HERMES_HOME", None), ("HOME", None), ("USERPROFILE", None)]); - let error = hermes_hooks_path(None).unwrap_err().to_string(); - assert!(error.contains("could not resolve home directory")); -} - -#[test] -fn hermes_patch_restore_restores_original_file() { - let _guard = current_dir_lock().lock().unwrap(); - let temp = tempfile::tempdir().unwrap(); - let previous = std::env::current_dir().unwrap(); - std::env::set_current_dir(temp.path()).unwrap(); - let hooks_path = temp.path().join("hermes-home/config.yaml"); - std::fs::create_dir_all(hooks_path.parent().unwrap()).unwrap(); - let original = "hooks:\n PreToolUse: []\n"; - std::fs::write(&hooks_path, original).unwrap(); - let resolved = ResolvedConfig { - gateway: GatewayConfig::default(), - agents: AgentConfigs { - hermes: AgentCommandConfig { - command: None, - hooks_path: Some(hooks_path.clone()), - }, - ..AgentConfigs::default() - }, - ..ResolvedConfig::default() - }; - - let prepared = PreparedRun::new( - CodingAgent::Hermes, - vec!["hermes".into(), "chat".into()], - "http://s", - &resolved, - false, - ) - .unwrap(); - - assert!( - std::fs::read_to_string(&hooks_path) - .unwrap() - .contains("hook-forward hermes") - ); - prepared.restore().unwrap(); - assert_eq!(std::fs::read_to_string(&hooks_path).unwrap(), original); - std::env::set_current_dir(previous).unwrap(); -} - -#[test] -fn prepares_claude_temp_plugin() { - let resolved = ResolvedConfig { - gateway: GatewayConfig::default(), - agents: AgentConfigs::default(), - ..ResolvedConfig::default() - }; - let prepared = PreparedRun::new( - CodingAgent::ClaudeCode, - vec!["claude".into()], - "http://127.0.0.1:1234", - &resolved, - false, - ) - .unwrap(); - - let plugin_index = prepared - .argv - .iter() - .position(|arg| arg == "--plugin-dir") - .unwrap(); - let plugin_dir = PathBuf::from(&prepared.argv[plugin_index + 1]); - assert!(plugin_dir.join("hooks/hooks.json").exists()); - assert!( - prepared - .env - .contains(&("ANTHROPIC_BASE_URL".into(), "http://127.0.0.1:1234".into())) - ); - prepared.restore().unwrap(); -} - -#[test] -fn hermes_restore_reports_restore_and_temporary_removal_failures() { - let temp = tempfile::tempdir().unwrap(); - let restore_missing_backup = PreparedRun { - argv: vec![], - env: vec![], - temp_dirs: vec![], - hermes_restore: Some(HermesRestore { - path: temp.path().join("config.yaml"), - backup_path: Some(temp.path().join("missing-backup.yaml")), - had_original: true, - }), - notes: vec![], - }; - - let error = restore_missing_backup.restore().unwrap_err().to_string(); - assert!(error.contains("failed to restore Hermes hooks")); - - let hooks_path = temp.path().join("hooks-dir"); - std::fs::create_dir(&hooks_path).unwrap(); - let remove_temporary_dir = PreparedRun { - argv: vec![], - env: vec![], - temp_dirs: vec![], - hermes_restore: Some(HermesRestore { - path: hooks_path, - backup_path: None, - had_original: false, - }), - notes: vec![], - }; - - let error = remove_temporary_dir.restore().unwrap_err().to_string(); - assert!(error.contains("failed to remove temporary Hermes hooks")); -} - -#[test] -fn hook_backup_and_write_helpers_cover_missing_existing_and_toml_escaping() { - let temp = tempfile::tempdir().unwrap(); - let missing_hermes = temp.path().join("missing-config.yaml"); - assert_eq!( - backup_existing_hermes_hooks(&missing_hermes).unwrap(), - (false, None) - ); - - let hermes_hooks = temp.path().join("config.yaml"); - std::fs::write(&hermes_hooks, "hooks: {}\n").unwrap(); - let (had_original, hermes_backup) = backup_existing_hermes_hooks(&hermes_hooks).unwrap(); - assert!(had_original); - assert!(hermes_backup.as_ref().unwrap().exists()); - - let written_hooks = temp.path().join("written/hooks.json"); - std::fs::create_dir_all(written_hooks.parent().unwrap()).unwrap(); - write_hooks(&written_hooks, json!({"hooks": []})).unwrap(); - assert!( - std::fs::read_to_string(&written_hooks) - .unwrap() - .contains("hooks") - ); - - let groups = hook_groups_toml(&json!([{ - "matcher": "Shell\"Run", - "hooks": [{"command": "nemo-relay \"quoted\""}] - }])); - assert!(groups.contains("matcher=\"Shell\\\"Run\"")); - assert!(groups.contains("command=\"nemo-relay \\\"quoted\\\"\"")); - - let escaped = toml_string(r#"C:\tmp\"quoted""#); - assert!(escaped.starts_with('"')); - assert!(escaped.ends_with('"')); - assert!(escaped.contains(r#"C:\\tmp\\"#)); - assert!(escaped.contains(r#"\"quoted\""#)); -} - -#[cfg(unix)] -#[test] -fn exit_code_preserves_normal_and_shell_wrapped_codes() { - let status = std::process::Command::new("/bin/sh") - .args(["-c", "exit 7"]) - .status() - .unwrap(); - assert_eq!(exit_code(status), ExitCode::from(7)); - - let status = std::process::Command::new("/bin/sh") - .args(["-c", "exit 300"]) - .status() - .unwrap(); - assert_eq!(exit_code(status), ExitCode::from(44)); -} - -// This e2e test relies on argv[0] being a script literally named after a known agent (so -// `CodingAgent::infer` recognises the basename without an explicit `--agent`). On Windows the -// only practical way to invoke a `.cmd` / `.bat` shim is via `cmd.exe /C script.cmd`, which -// makes argv[0] = `cmd.exe` and breaks inference. Gating Unix-only keeps cross-platform CI -// green; real Windows agent-spawn coverage can come back with a `.exe` fake binary once the -// launcher grows Windows support. -#[cfg(unix)] -#[tokio::test] -async fn run_starts_gateway_injects_env_and_returns_agent_exit_code() { - let temp = tempfile::tempdir().unwrap(); - let config = temp.path().join("config.toml"); - std::fs::write(&config, "[upstream]\n").unwrap(); - let output = temp.path().join("env.txt"); - let command_argv = fake_agent_command(temp.path(), &output); - let command = RunCommand { - // Leave `agent: None` so the launcher infers from argv[0] and uses `command_argv` - // (our fake-agent.sh) as the full argv. With --agent set, the resolver appends - // command as pass-through after the configured/default binary — not what this test - // wants, since it specifically asserts that argv[0] is the fake script. - agent: None, - config: Some(config), - openai_base_url: None, - anthropic_base_url: None, - session_metadata: None, - plugin_config_path: None, - dry_run: false, - print: false, - command: command_argv, - }; - - let code = run(command, None).await.unwrap(); - - assert_eq!(code, ExitCode::from(7)); - let url = std::fs::read_to_string(output).unwrap(); - assert!(url.starts_with("http://127.0.0.1:")); - assert!(!url.ends_with(":0")); -} - -#[cfg(unix)] -fn fake_agent_command(temp: &Path, output: &Path) -> Vec { - // Name the script `codex` (not `fake-agent.sh`) so `CodingAgent::infer` recognizes the - // argv[0] basename without us needing to set `--agent` explicitly. With `--agent` set, - // the resolver appends `command.command` as pass-through args after the configured/default - // binary — wrong for this test, which wants the fake script itself to be argv[0]. - let script = temp.join("codex"); - std::fs::write( - &script, - format!( - "#!/bin/sh\nprintf '%s' \"$NEMO_RELAY_GATEWAY_URL\" > \"{}\"\nexit 7\n", - output.display() - ), - ) - .unwrap(); - make_executable(&script); - vec![script.display().to_string()] -} - -#[tokio::test] -async fn dry_run_does_not_spawn_agent() { - let command = RunCommand { - agent: Some(CodingAgent::Codex), - config: None, - openai_base_url: None, - anthropic_base_url: None, - session_metadata: None, - plugin_config_path: None, - dry_run: true, - print: false, - command: vec!["/path/that/does/not/exist".into()], - }; - - let code = run(command, None).await.unwrap(); - - assert_eq!(code, ExitCode::SUCCESS); -} - -#[tokio::test] -async fn dry_run_does_not_hydrate_dynamic_plugin_lifecycle_state() { - let temp = tempfile::tempdir().unwrap(); - let plugin_dir = temp.path().join("plugins/acme"); - std::fs::create_dir_all(&plugin_dir).unwrap(); - let manifest_path = plugin_dir.join("relay-plugin.toml"); - std::fs::write( - &manifest_path, - format!( - r#" -manifest_version = 1 - -[plugin] -id = "acme.worker" -kind = "worker" - -[compat] -relay = "={version}" -worker_protocol = "grpc-v1" - -[capabilities] -items = ["plugin_worker"] - -[defaults] - -[load] -runtime = "python" -entrypoint = "acme.worker:create_plugin" -"#, - version = env!("CARGO_PKG_VERSION"), - ), - ) - .unwrap(); - let config_path = temp.path().join("config.toml"); - std::fs::write(&config_path, "").unwrap(); - std::fs::write( - temp.path().join("plugins.toml"), - format!( - "[[plugins.dynamic]]\nmanifest = {:?}\n", - manifest_path.to_string_lossy() - ), - ) - .unwrap(); - - let command = RunCommand { - agent: Some(CodingAgent::Codex), - config: Some(config_path), - openai_base_url: None, - anthropic_base_url: None, - session_metadata: None, - plugin_config_path: None, - dry_run: true, - print: false, - command: vec!["codex".into()], - }; - - let code = run(command, None).await.unwrap(); - - assert_eq!(code, ExitCode::SUCCESS); - assert!(!temp.path().join(".dynamic-plugins.json").exists()); -} - -#[tokio::test] -async fn wait_for_health_reports_unready_gateway() { - let error = wait_for_health("http://127.0.0.1:1") - .await - .unwrap_err() - .to_string(); - - assert!(error.contains("gateway did not become ready")); -} - -#[tokio::test] -async fn execute_live_run_reports_gateway_startup_error_when_health_check_fails() { - let resolved = ResolvedConfig { - gateway: GatewayConfig::default(), - agents: AgentConfigs::default(), - ..ResolvedConfig::default() - }; - let prepared = PreparedRun::new( - CodingAgent::ClaudeCode, - vec!["claude".into()], - "http://127.0.0.1:1234", - &resolved, - false, - ) - .unwrap(); - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let gateway_url = format!("http://{}", listener.local_addr().unwrap()); - let gateway_config = GatewayConfig { - plugin_config: Some(json!({ - "version": 1, - "components": [{ - "kind": OBSERVABILITY_PLUGIN_KIND, - "enabled": true, - "config": { - "version": 1, - "atof": { - "enabled": true, - "mode": "invalid" - } - } - }] - })), - ..GatewayConfig::default() - }; - - let error = execute_live_run(listener, gateway_config, &gateway_url, prepared) - .await - .unwrap_err() - .to_string(); - - assert!(error.contains("ATOF mode")); - assert!(!error.contains("gateway did not become ready")); -} - -#[tokio::test] -async fn execute_live_run_restores_hermes_hooks_when_health_check_fails() { - let temp = tempfile::tempdir().unwrap(); - let hooks_path = temp.path().join("hermes-home/config.yaml"); - std::fs::create_dir_all(hooks_path.parent().unwrap()).unwrap(); - let original = "hooks:\n PreToolUse: []\n"; - std::fs::write(&hooks_path, original).unwrap(); - let resolved = ResolvedConfig { - gateway: GatewayConfig::default(), - agents: AgentConfigs { - hermes: AgentCommandConfig { - command: None, - hooks_path: Some(hooks_path.clone()), - }, - ..AgentConfigs::default() - }, - ..ResolvedConfig::default() - }; - let prepared = PreparedRun::new( - CodingAgent::Hermes, - vec!["hermes".into(), "chat".into()], - "http://127.0.0.1:1234", - &resolved, - false, - ) - .unwrap(); - assert!( - std::fs::read_to_string(&hooks_path) - .unwrap() - .contains("hook-forward hermes") - ); - - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let error = execute_live_run( - listener, - GatewayConfig::default(), - "http://127.0.0.1:1", - prepared, - ) - .await - .unwrap_err() - .to_string(); - - assert!(error.contains("gateway did not become ready")); - assert_eq!(std::fs::read_to_string(&hooks_path).unwrap(), original); -} - -#[cfg(unix)] -fn make_executable(path: &Path) { - use std::os::unix::fs::PermissionsExt; - let mut permissions = std::fs::metadata(path).unwrap().permissions(); - permissions.set_mode(0o755); - std::fs::set_permissions(path, permissions).unwrap(); -} diff --git a/crates/cli/tests/coverage/plugin_install_tests.rs b/crates/cli/tests/coverage/plugin_install_tests.rs deleted file mode 100644 index 5a0776dd8..000000000 --- a/crates/cli/tests/coverage/plugin_install_tests.rs +++ /dev/null @@ -1,1789 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -use std::cell::RefCell; -use std::collections::HashMap; -use std::ffi::OsString; -use std::path::{Path, PathBuf}; -use std::sync::Mutex; - -use serde_json::json; -use tempfile::tempdir; - -use super::host::{ - CommandOutput, HostRegistrationReport, format_command, host_registration_report, - require_host_cli, require_relay, run_capture_command, run_command, run_path_command, - validate_host_registration, validate_relay_plugin_shim, -}; -use super::*; - -fn plugin_install_env_lock() -> &'static Mutex<()> { - &crate::test_support::ENV_TEST_LOCK -} - -struct HomeScope<'a> { - _guard: std::sync::MutexGuard<'a, ()>, - prev_home: Option, - prev_userprofile: Option, -} - -impl<'a> HomeScope<'a> { - fn enter(path: &Path) -> Self { - let guard = plugin_install_env_lock() - .lock() - .unwrap_or_else(|error| error.into_inner()); - let prev_home = std::env::var_os("HOME"); - let prev_userprofile = std::env::var_os("USERPROFILE"); - // SAFETY: This test holds a process-wide mutex for the lifetime of the env override. - unsafe { - std::env::set_var("HOME", path); - std::env::remove_var("USERPROFILE"); - } - Self { - _guard: guard, - prev_home, - prev_userprofile, - } - } -} - -impl Drop for HomeScope<'_> { - fn drop(&mut self) { - // SAFETY: This restores the process environment while the mutex is still held. - unsafe { - match self.prev_home.take() { - Some(value) => std::env::set_var("HOME", value), - None => std::env::remove_var("HOME"), - } - match self.prev_userprofile.take() { - Some(value) => std::env::set_var("USERPROFILE", value), - None => std::env::remove_var("USERPROFILE"), - } - } - } -} - -struct PathScope<'a> { - _guard: std::sync::MutexGuard<'a, ()>, - previous: Option, -} - -impl<'a> PathScope<'a> { - fn set(path: &Path) -> Self { - let guard = plugin_install_env_lock() - .lock() - .unwrap_or_else(|error| error.into_inner()); - let previous = std::env::var_os("PATH"); - // SAFETY: This test holds the process-wide environment mutex for the override lifetime. - unsafe { - std::env::set_var("PATH", path); - } - Self { - _guard: guard, - previous, - } - } -} - -impl Drop for PathScope<'_> { - fn drop(&mut self) { - // SAFETY: This restores PATH while the process-wide environment mutex is still held. - unsafe { - match self.previous.take() { - Some(value) => std::env::set_var("PATH", value), - None => std::env::remove_var("PATH"), - } - } - } -} - -#[derive(Default)] -struct MockRunner { - executables: HashMap, - commands: RefCell>, - quiet_commands: RefCell>, - capture_commands: RefCell>, - capture_outputs: HashMap, - failing_suffix: Option, - failing_suffixes: Vec, - failing_quiet_suffix: Option, -} - -impl MockRunner { - fn with_executable(mut self, name: &str, path: &str) -> Self { - self.executables.insert(name.into(), PathBuf::from(path)); - self - } - - fn with_capture_output(mut self, command: &str, stdout: impl Into) -> Self { - self.capture_outputs - .insert(command.into(), CommandOutput::success(stdout.into())); - self - } - - fn with_capture_status( - mut self, - command: &str, - status: i32, - stdout: impl Into, - stderr: impl Into, - ) -> Self { - self.capture_outputs.insert( - command.into(), - CommandOutput { - status, - stdout: stdout.into(), - stderr: stderr.into(), - }, - ); - self - } - - fn commands(&self) -> Vec { - self.commands.borrow().clone() - } - - fn quiet_commands(&self) -> Vec { - self.quiet_commands.borrow().clone() - } - - fn capture_commands(&self) -> Vec { - self.capture_commands.borrow().clone() - } -} - -impl CommandRunner for MockRunner { - fn resolve_executable(&self, command: &str) -> Result, String> { - Ok(self.executables.get(command).cloned()) - } - - fn run(&self, program: &Path, args: &[String]) -> Result { - let rendered = format!( - "{} {}", - program.display(), - args.iter() - .map(String::as_str) - .collect::>() - .join(" ") - ); - self.commands.borrow_mut().push(rendered.clone()); - Ok( - if command_matches_suffix(&rendered, self.failing_suffix.as_deref()) - || self - .failing_suffixes - .iter() - .any(|suffix| rendered.ends_with(suffix)) - { - 1 - } else { - 0 - }, - ) - } - - fn run_quiet(&self, program: &Path, args: &[String]) -> Result { - let rendered = format!( - "{} {}", - program.display(), - args.iter() - .map(String::as_str) - .collect::>() - .join(" ") - ); - self.quiet_commands.borrow_mut().push(rendered.clone()); - Ok( - if command_matches_suffix(&rendered, self.failing_quiet_suffix.as_deref()) { - 1 - } else { - 0 - }, - ) - } - - fn run_capture(&self, program: &Path, args: &[String]) -> Result { - let rendered = format!( - "{} {}", - program.display(), - args.iter() - .map(String::as_str) - .collect::>() - .join(" ") - ); - self.capture_commands.borrow_mut().push(rendered.clone()); - Ok(self - .capture_outputs - .get(&rendered) - .cloned() - .unwrap_or_else(|| CommandOutput::success(String::new()))) - } -} - -fn command_matches_suffix(command: &str, suffix: Option<&str>) -> bool { - suffix.is_some_and(|suffix| command.ends_with(suffix)) -} - -#[derive(Default)] -struct MockSetupRunner { - calls: RefCell>, - failing_call: Option, -} - -impl MockSetupRunner { - fn calls(&self) -> Vec { - self.calls.borrow().clone() - } -} - -impl PluginSetupRunner for MockSetupRunner { - fn setup(&self, host: PluginHost, gateway_url: &str) -> Result<(), String> { - self.record(format!("setup {} {gateway_url}", host_arg(host))) - } - - fn uninstall(&self, host: PluginHost, gateway_url: &str) -> Result<(), String> { - self.record(format!("uninstall {} {gateway_url}", host_arg(host))) - } - - fn doctor(&self, host: PluginHost, gateway_url: &str) -> Result<(), String> { - self.record(format!("doctor {} {gateway_url}", host_arg(host))) - } - - fn doctor_json( - &self, - host: PluginHost, - gateway_url: &str, - ) -> Result { - self.record(format!("doctor-json {} {gateway_url}", host_arg(host)))?; - Ok(json!({ - "ok": true, - "checks": {} - })) - } -} - -impl MockSetupRunner { - fn record(&self, call: String) -> Result<(), String> { - self.calls.borrow_mut().push(call.clone()); - if self.failing_call.as_deref() == Some(call.as_str()) { - Err(format!("{call} failed")) - } else { - Ok(()) - } - } -} - -fn options(dir: &Path) -> PluginInstallOptions { - PluginInstallOptions { - install_dir: dir.to_path_buf(), - force: false, - dry_run: false, - skip_doctor: true, - } -} - -fn relay_validation_command() -> String { - "/bin/nemo-relay plugin-shim hook --help".into() -} - -fn write_installed_state(host: PluginHost, dir: &Path) { - let layout = PluginLayout::new(host, dir); - write_plugin_marketplace(host, &layout, &options(dir)).unwrap(); - write_state(&layout, &options(dir)).unwrap(); - mark_plugin_setup_installed(host, &layout, &options(dir)).unwrap(); -} - -#[test] -fn default_install_dir_follows_platform_conventions() { - assert_eq!( - default_install_dir_for("macos", Some("/Users/example".into()), None, None, None), - PathBuf::from("/Users/example/Library/Application Support/nemo-relay/plugins") - ); - assert_eq!( - default_install_dir_for("linux", Some("/home/example".into()), None, None, None), - PathBuf::from("/home/example/.local/share/nemo-relay/plugins") - ); - assert_eq!( - default_install_dir_for( - "linux", - Some("/home/example".into()), - None, - None, - Some("/data".into()) - ), - PathBuf::from("/data/nemo-relay/plugins") - ); - assert_eq!( - default_install_dir_for( - "windows", - None, - Some(r"C:\Users\example".into()), - Some(r"C:\Users\example\AppData\Local".into()), - None - ), - PathBuf::from(r"C:\Users\example\AppData\Local") - .join("nemo-relay") - .join("plugins") - ); -} - -#[test] -fn plugin_manifests_and_hooks_use_path_based_relay_command() { - assert_eq!( - marketplace_manifest(PluginHost::Codex)["name"], - json!(MARKETPLACE_NAME) - ); - assert_eq!( - marketplace_manifest(PluginHost::ClaudeCode)["plugins"][0]["source"], - json!("./plugins/nemo-relay-plugin") - ); - assert_eq!( - plugin_manifest(PluginHost::Codex)["name"], - json!(PLUGIN_NAME) - ); - assert_eq!( - plugin_hooks(PluginHost::Codex)["hooks"]["SessionStart"][0]["hooks"][0]["command"], - json!("nemo-relay plugin-shim hook codex") - ); - assert_eq!( - plugin_hooks(PluginHost::ClaudeCode)["hooks"]["SessionStart"][0]["hooks"][0]["command"], - json!("nemo-relay plugin-shim hook claude") - ); -} - -#[test] -fn plugin_setup_delegates_and_dry_run_skips_runner_calls() { - let dir = tempdir().unwrap(); - let setup_runner = MockSetupRunner::default(); - let dry_run = PluginInstallOptions { - dry_run: true, - ..options(dir.path()) - }; - - run_plugin_setup(PluginHost::Codex, &dry_run, &setup_runner).unwrap(); - run_plugin_uninstall(PluginHost::ClaudeCode, &dry_run, &setup_runner).unwrap(); - run_plugin_doctor(PluginHost::Codex, &dry_run, &setup_runner).unwrap(); - assert!(setup_runner.calls().is_empty()); - - let normal = options(dir.path()); - run_plugin_setup(PluginHost::Codex, &normal, &setup_runner).unwrap(); - run_plugin_uninstall(PluginHost::ClaudeCode, &normal, &setup_runner).unwrap(); - run_plugin_doctor(PluginHost::Codex, &normal, &setup_runner).unwrap(); - let report = run_plugin_doctor_json(PluginHost::ClaudeCode, &setup_runner).unwrap(); - - assert_eq!( - setup_runner.calls(), - vec![ - format!("setup codex {DEFAULT_GATEWAY_URL}"), - format!("uninstall claude-code {DEFAULT_GATEWAY_URL}"), - format!("doctor codex {DEFAULT_GATEWAY_URL}"), - format!("doctor-json claude-code {DEFAULT_GATEWAY_URL}"), - ] - ); - assert_eq!(report["ok"], json!(true)); -} - -#[test] -fn real_plugin_setup_runner_uses_temp_home_for_codex_and_claude_paths() { - let dir = tempdir().unwrap(); - let _home = HomeScope::enter(dir.path()); - let runner = RealPluginSetupRunner; - - runner - .setup(PluginHost::Codex, DEFAULT_GATEWAY_URL) - .unwrap(); - assert!( - runner - .doctor(PluginHost::Codex, DEFAULT_GATEWAY_URL) - .is_ok() - ); - let codex_report = runner - .doctor_json(PluginHost::Codex, DEFAULT_GATEWAY_URL) - .unwrap(); - assert_eq!(codex_report["checks"]["codex_provider_alias"], json!(true)); - assert_eq!(codex_report["checks"]["codex_hooks"], json!(true)); - runner - .uninstall(PluginHost::Codex, DEFAULT_GATEWAY_URL) - .unwrap(); - - runner - .setup(PluginHost::ClaudeCode, DEFAULT_GATEWAY_URL) - .unwrap(); - assert!( - runner - .doctor(PluginHost::ClaudeCode, DEFAULT_GATEWAY_URL) - .is_ok() - ); - let claude_report = runner - .doctor_json(PluginHost::ClaudeCode, DEFAULT_GATEWAY_URL) - .unwrap(); - assert_eq!( - claude_report["checks"]["claude_provider_routing"], - json!(true) - ); - runner - .uninstall(PluginHost::ClaudeCode, DEFAULT_GATEWAY_URL) - .unwrap(); -} - -#[test] -fn setup_action_descriptions_cover_supported_hosts_and_actions() { - assert_eq!( - setup_action_description(PluginHost::Codex, "configure"), - "configure Codex provider and hook-supervised lazy startup" - ); - assert_eq!( - setup_action_description(PluginHost::Codex, "restore"), - "restore Codex provider and generated hook configuration" - ); - assert_eq!( - setup_action_description(PluginHost::Codex, "doctor"), - "check Codex provider and generated hooks" - ); - assert_eq!( - setup_action_description(PluginHost::ClaudeCode, "configure"), - "enable Claude Code provider routing through NeMo Relay" - ); - assert_eq!( - setup_action_description(PluginHost::ClaudeCode, "restore"), - "restore Claude Code provider routing from NeMo Relay backup" - ); - assert_eq!( - setup_action_description(PluginHost::ClaudeCode, "doctor"), - "check Claude Code provider routing" - ); -} - -#[test] -fn host_command_helpers_cover_dry_run_missing_failure_and_reporting() { - let dir = tempdir().unwrap(); - let dry_run = PluginInstallOptions { - dry_run: true, - ..options(dir.path()) - }; - let runner = MockRunner::default(); - - assert_eq!( - require_relay(&dry_run, &runner).unwrap(), - PathBuf::from(RELAY_COMMAND) - ); - require_host_cli(PluginHost::Codex, &dry_run, &runner).unwrap(); - validate_relay_plugin_shim(Path::new("nemo-relay"), &dry_run, &runner).unwrap(); - run_command( - "codex", - &["plugin".into(), "add space".into()], - &dry_run, - &runner, - ) - .unwrap(); - run_path_command( - Path::new("/bin/codex"), - &["arg with space".into()], - &dry_run, - &runner, - ) - .unwrap(); - let capture = run_capture_command("codex", &["plugin".into()], &dry_run, &runner).unwrap(); - assert_eq!(capture.stdout, "null\n"); - let report = host_registration_report(PluginHost::Codex, &dry_run, &runner).unwrap(); - assert!(report.ok()); - assert_eq!(report.to_json()["ok"], json!(true)); - assert_eq!( - HostRegistrationReport { - host_plugin_registered: false, - host_marketplace_registered: true, - } - .to_json()["host_plugin_registered"], - json!(false) - ); - - let normal = options(dir.path()); - assert!( - require_relay(&normal, &runner) - .unwrap_err() - .contains("nemo-relay") - ); - assert!( - require_host_cli(PluginHost::Codex, &normal, &runner) - .unwrap_err() - .contains("codex") - ); - assert!( - run_command("codex", &["plugin".into()], &normal, &runner) - .unwrap_err() - .contains("codex") - ); - - let mut runner = MockRunner::default() - .with_executable("nemo-relay", "/bin/nemo-relay") - .with_executable("codex", "/bin/codex"); - runner.failing_quiet_suffix = Some("plugin-shim hook --help".into()); - assert!( - validate_relay_plugin_shim(Path::new("/bin/nemo-relay"), &normal, &runner) - .unwrap_err() - .contains("plugin-shim hook") - ); - runner.failing_suffix = Some("plugin add".into()); - assert!( - run_path_command( - Path::new("/bin/codex"), - &["plugin".into(), "add".into()], - &normal, - &runner - ) - .unwrap_err() - .contains("exit code 1") - ); - let quoted = format_command( - "codex", - &["plugin".into(), "arg with space".into(), "quote\"$".into()], - ); - assert!(quoted.contains("\"arg with space\"")); - assert!(quoted.contains("\"quote\\\"\\$\"")); - - let runner = MockRunner::default() - .with_executable("codex", "/bin/codex") - .with_capture_status("/bin/codex plugin bad", 2, "", "") - .with_capture_status("/bin/codex plugin noisy", 3, "", "boom"); - assert!( - run_capture_command("codex", &["plugin".into(), "bad".into()], &normal, &runner) - .unwrap_err() - .contains("exit code 2") - ); - assert!( - run_capture_command( - "codex", - &["plugin".into(), "noisy".into()], - &normal, - &runner - ) - .unwrap_err() - .contains(": boom") - ); - - let runner = MockRunner::default() - .with_executable("codex", "/bin/codex") - .with_capture_output("/bin/codex plugin list", "PLUGIN STATUS VERSION PATH\n") - .with_capture_output("/bin/codex plugin marketplace list", "MARKETPLACE ROOT\n"); - let error = validate_host_registration(PluginHost::Codex, &normal, &runner).unwrap_err(); - assert!( - error.contains("host plugin") && error.contains("host marketplace"), - "error was: {error}" - ); -} - -#[test] -fn host_registration_report_accepts_claude_and_codex_shape_variants() { - let dir = tempdir().unwrap(); - let normal = options(dir.path()); - let plugin_id = format!("{PLUGIN_NAME}@{MARKETPLACE_NAME}"); - - for (plugin_entry, marketplace_entry) in [ - ( - json!({"id": plugin_id.clone()}), - json!({"id": MARKETPLACE_NAME}), - ), - ( - json!({"pluginId": plugin_id.clone()}), - json!({"name": MARKETPLACE_NAME}), - ), - ( - json!({"name": PLUGIN_NAME, "marketplaceName": MARKETPLACE_NAME}), - json!({"id": MARKETPLACE_NAME}), - ), - ] { - let runner = MockRunner::default() - .with_executable("claude", "/bin/claude") - .with_capture_output( - "/bin/claude plugin list --json", - json!([plugin_entry]).to_string(), - ) - .with_capture_output( - "/bin/claude plugin marketplace list --json", - json!([marketplace_entry]).to_string(), - ); - let report = host_registration_report(PluginHost::ClaudeCode, &normal, &runner).unwrap(); - assert!(report.ok()); - assert!(report.host_plugin_registered); - assert!(report.host_marketplace_registered); - } - - let runner = MockRunner::default() - .with_executable("codex", "/bin/codex") - .with_capture_output( - "/bin/codex plugin list", - format!("{plugin_id} installed, enabled 0.4.0 /tmp/nemo-relay-plugin\n"), - ) - .with_capture_output( - "/bin/codex plugin marketplace list", - format!("{MARKETPLACE_NAME} /tmp/nemo-relay-local\n"), - ); - let report = host_registration_report(PluginHost::Codex, &normal, &runner).unwrap(); - assert!(report.ok()); - - let runner = MockRunner::default() - .with_executable("codex", "/bin/codex") - .with_capture_output( - "/bin/codex plugin list", - format!("{plugin_id} not installed\n"), - ) - .with_capture_output( - "/bin/codex plugin marketplace list", - format!("{MARKETPLACE_NAME} /tmp/nemo-relay-local\n"), - ); - let report = host_registration_report(PluginHost::Codex, &normal, &runner).unwrap(); - assert!(!report.host_plugin_registered); - assert!(report.host_marketplace_registered); - - let runner = MockRunner::default() - .with_executable("codex", "/bin/codex") - .with_capture_output( - "/bin/codex plugin list", - format!("{PLUGIN_NAME}@other installed, enabled 0.4.0 /tmp/other\n"), - ) - .with_capture_output("/bin/codex plugin marketplace list", "other /tmp/other\n"); - let report = host_registration_report(PluginHost::Codex, &normal, &runner).unwrap(); - assert!(!report.ok()); - assert!(!report.host_plugin_registered); - assert!(!report.host_marketplace_registered); -} - -#[test] -fn host_registration_report_surfaces_capture_status_and_stderr_variants() { - let dir = tempdir().unwrap(); - let normal = options(dir.path()); - - let runner = MockRunner::default() - .with_executable("claude", "/bin/claude") - .with_capture_output("/bin/claude plugin list --json", "not json"); - assert!( - host_registration_report(PluginHost::ClaudeCode, &normal, &runner) - .unwrap_err() - .contains("failed to parse") - ); - - let runner = MockRunner::default() - .with_executable("claude", "/bin/claude") - .with_capture_status( - "/bin/claude plugin list --json", - 4, - "ignored stdout", - " noisy failure \n", - ); - let error = host_registration_report(PluginHost::ClaudeCode, &normal, &runner).unwrap_err(); - assert!(error.contains("exit code 4: noisy failure")); - - let runner = MockRunner::default() - .with_executable("claude", "/bin/claude") - .with_capture_output( - "/bin/claude plugin list --json", - json!([{ "id": format!("{PLUGIN_NAME}@{MARKETPLACE_NAME}") }]).to_string(), - ) - .with_capture_status( - "/bin/claude plugin marketplace list --json", - 5, - "ignored stdout", - "", - ); - let error = host_registration_report(PluginHost::ClaudeCode, &normal, &runner).unwrap_err(); - assert!(error.contains("exit code 5")); - assert!(!error.contains("exit code 5:")); -} - -#[test] -fn top_level_install_uninstall_and_doctor_report_empty_host_selection() { - let dir = tempdir().unwrap(); - let empty_path = dir.path().join("empty-path"); - std::fs::create_dir_all(&empty_path).unwrap(); - let _path = PathScope::set(&empty_path); - - let install_error = install(crate::config::InstallCommand { - host: PluginHost::All, - install_dir: Some(dir.path().join("install")), - force: false, - dry_run: false, - skip_doctor: true, - }) - .unwrap_err() - .to_string(); - assert!( - install_error.contains("no supported Claude Code or Codex host CLI"), - "error was: {install_error}" - ); - - let uninstall_error = uninstall(crate::config::UninstallCommand { - host: PluginHost::All, - install_dir: Some(dir.path().join("install")), - dry_run: false, - }) - .unwrap_err() - .to_string(); - assert!( - uninstall_error.contains("no installed Claude Code or Codex plugin state"), - "error was: {uninstall_error}" - ); - - let doctor_error = doctor(PluginHost::All, Some(dir.path().join("install")), true) - .unwrap_err() - .to_string(); - assert!( - doctor_error.contains("no installed Claude Code or Codex plugin state"), - "error was: {doctor_error}" - ); - let doctor_human_error = doctor(PluginHost::All, Some(dir.path().join("install")), false) - .unwrap_err() - .to_string(); - assert!( - doctor_human_error.contains("no installed Claude Code or Codex plugin state"), - "error was: {doctor_human_error}" - ); - - assert_eq!( - install(crate::config::InstallCommand { - host: PluginHost::Codex, - install_dir: Some(dir.path().join("dry-run-install")), - force: false, - dry_run: true, - skip_doctor: true, - }) - .unwrap(), - std::process::ExitCode::SUCCESS - ); - - let codex_doctor_error = doctor(PluginHost::Codex, Some(dir.path().join("install")), false) - .unwrap_err() - .to_string(); - assert!( - codex_doctor_error.contains("nemo-relay install codex --force"), - "error was: {codex_doctor_error}" - ); - - let codex_uninstall_error = uninstall(crate::config::UninstallCommand { - host: PluginHost::Codex, - install_dir: Some(dir.path().join("install")), - dry_run: false, - }) - .unwrap_err() - .to_string(); - assert!( - codex_uninstall_error.contains("required `codex` CLI"), - "error was: {codex_uninstall_error}" - ); - - assert_eq!(host_arg(PluginHost::All), "all"); - assert_eq!(host_label(PluginHost::All), "all"); - print_json(&json!({"ok": true})).unwrap(); - assert_eq!( - with_schema(json!({"ok": true})), - json!({"ok": true, "schema_version": 1}) - ); - assert_eq!(with_schema(json!("not-an-object")), json!("not-an-object")); - assert!(std::panic::catch_unwind(|| host_cli(PluginHost::All)).is_err()); -} - -#[test] -fn select_all_uses_operation_specific_inputs() { - let dir = tempdir().unwrap(); - let runner = MockRunner::default().with_executable("codex", "/bin/codex"); - let selected = select_hosts( - PluginHost::All, - HostSelectionMode::Install, - &options(dir.path()), - &runner, - ) - .unwrap(); - assert_eq!(selected, vec![PluginHost::Codex]); - - std::fs::write( - state_path(PluginHost::ClaudeCode, dir.path()), - r#"{"marketplaceRoot":"/tmp/m","pluginRoot":"/tmp/p"}"#, - ) - .unwrap(); - let selected = select_hosts( - PluginHost::All, - HostSelectionMode::Install, - &options(dir.path()), - &runner, - ) - .unwrap(); - assert_eq!(selected, vec![PluginHost::Codex]); - - let selected = select_hosts( - PluginHost::All, - HostSelectionMode::InstalledState, - &options(dir.path()), - &runner, - ) - .unwrap(); - assert_eq!(selected, vec![PluginHost::ClaudeCode]); -} - -#[test] -fn install_codex_generates_marketplace_and_runs_setup() { - let dir = tempdir().unwrap(); - let runner = MockRunner::default() - .with_executable("nemo-relay", "/bin/nemo-relay") - .with_executable("codex", "/bin/codex"); - let setup_runner = MockSetupRunner::default(); - - install_host( - PluginHost::Codex, - &options(dir.path()), - &runner, - &setup_runner, - ) - .unwrap(); - - let layout = PluginLayout::new(PluginHost::Codex, dir.path()); - assert!( - !layout.hooks_path.exists(), - "generated Codex marketplace must not also install plugin hook templates" - ); - assert_eq!( - runner.commands(), - vec![ - format!( - "/bin/codex plugin marketplace add {}", - layout.marketplace_root.display() - ), - "/bin/codex plugin add nemo-relay-plugin@nemo-relay-local".into(), - ] - ); - assert_eq!(runner.quiet_commands(), vec![relay_validation_command()]); - assert_eq!( - setup_runner.calls(), - vec![format!("setup codex {DEFAULT_GATEWAY_URL}")] - ); -} - -#[test] -fn install_prunes_stale_managed_plugin_root() { - let dir = tempdir().unwrap(); - let runner = MockRunner::default() - .with_executable("nemo-relay", "/bin/nemo-relay") - .with_executable("claude", "/bin/claude"); - let setup_runner = MockSetupRunner::default(); - let layout = PluginLayout::new(PluginHost::ClaudeCode, dir.path()); - let stale = layout.plugin_root.join("bin").join("nemo-relay"); - std::fs::create_dir_all(stale.parent().unwrap()).unwrap(); - std::fs::write(&stale, "stale").unwrap(); - - install_host( - PluginHost::ClaudeCode, - &options(dir.path()), - &runner, - &setup_runner, - ) - .unwrap(); - - assert!(!stale.exists()); - assert!(layout.plugin_manifest.exists()); -} - -#[test] -fn force_install_unregisters_existing_host_before_reinstall() { - let dir = tempdir().unwrap(); - let runner = MockRunner::default() - .with_executable("nemo-relay", "/bin/nemo-relay") - .with_executable("codex", "/bin/codex"); - let setup_runner = MockSetupRunner::default(); - let options = PluginInstallOptions { - force: true, - ..options(dir.path()) - }; - write_installed_state(PluginHost::Codex, dir.path()); - - install_host(PluginHost::Codex, &options, &runner, &setup_runner).unwrap(); - - let commands = runner.commands(); - let remove_index = commands - .iter() - .position(|command| { - command == "/bin/codex plugin remove nemo-relay-plugin@nemo-relay-local" - }) - .unwrap(); - let add_index = commands - .iter() - .position(|command| command.ends_with("plugin add nemo-relay-plugin@nemo-relay-local")) - .unwrap(); - assert!(remove_index < add_index); - assert!( - setup_runner - .calls() - .iter() - .any(|call| call == &format!("uninstall codex {DEFAULT_GATEWAY_URL}")) - ); -} - -#[test] -fn force_install_without_state_unregisters_host_before_reinstall() { - let dir = tempdir().unwrap(); - let runner = MockRunner::default() - .with_executable("nemo-relay", "/bin/nemo-relay") - .with_executable("codex", "/bin/codex"); - let setup_runner = MockSetupRunner::default(); - let options = PluginInstallOptions { - force: true, - ..options(dir.path()) - }; - - install_host(PluginHost::Codex, &options, &runner, &setup_runner).unwrap(); - - let commands = runner.commands(); - let remove_index = commands - .iter() - .position(|command| { - command == "/bin/codex plugin remove nemo-relay-plugin@nemo-relay-local" - }) - .unwrap(); - let add_index = commands - .iter() - .position(|command| command.ends_with("plugin add nemo-relay-plugin@nemo-relay-local")) - .unwrap(); - assert!(remove_index < add_index); -} - -#[test] -fn install_claude_enables_provider_routing() { - let dir = tempdir().unwrap(); - let runner = MockRunner::default() - .with_executable("nemo-relay", "/bin/nemo-relay") - .with_executable("claude", "/bin/claude"); - let setup_runner = MockSetupRunner::default(); - - install_host( - PluginHost::ClaudeCode, - &options(dir.path()), - &runner, - &setup_runner, - ) - .unwrap(); - - let layout = PluginLayout::new(PluginHost::ClaudeCode, dir.path()); - assert_eq!( - runner.commands(), - vec![ - format!( - "/bin/claude plugin marketplace add {}", - layout.marketplace_root.display() - ), - "/bin/claude plugin install nemo-relay-plugin@nemo-relay-local --scope user".into(), - ] - ); - assert_eq!(runner.quiet_commands(), vec![relay_validation_command()]); - assert_eq!( - setup_runner.calls(), - vec![format!("setup claude-code {DEFAULT_GATEWAY_URL}")] - ); -} - -#[test] -fn missing_relay_path_fails_before_generating_plugin() { - let dir = tempdir().unwrap(); - let runner = MockRunner::default().with_executable("codex", "/bin/codex"); - let setup_runner = MockSetupRunner::default(); - - let error = install_host( - PluginHost::Codex, - &options(dir.path()), - &runner, - &setup_runner, - ) - .unwrap_err(); - - assert!(error.contains("nemo-relay")); - assert!( - !PluginLayout::new(PluginHost::Codex, dir.path()) - .marketplace_root - .exists() - ); -} - -#[test] -fn unsupported_relay_path_fails_before_generating_plugin() { - let dir = tempdir().unwrap(); - let mut runner = MockRunner::default() - .with_executable("nemo-relay", "/bin/nemo-relay") - .with_executable("codex", "/bin/codex"); - runner.failing_quiet_suffix = Some("plugin-shim hook --help".into()); - let setup_runner = MockSetupRunner::default(); - - let error = install_host( - PluginHost::Codex, - &options(dir.path()), - &runner, - &setup_runner, - ) - .unwrap_err(); - - assert!(error.contains("plugin-shim hook")); - assert!( - !PluginLayout::new(PluginHost::Codex, dir.path()) - .marketplace_root - .exists() - ); -} - -#[test] -fn setup_failure_rolls_back_generated_files_and_registration() { - let dir = tempdir().unwrap(); - let runner = MockRunner::default() - .with_executable("nemo-relay", "/bin/nemo-relay") - .with_executable("claude", "/bin/claude"); - let setup_runner = MockSetupRunner { - failing_call: Some(format!("setup claude-code {DEFAULT_GATEWAY_URL}")), - ..MockSetupRunner::default() - }; - - let error = install_host( - PluginHost::ClaudeCode, - &options(dir.path()), - &runner, - &setup_runner, - ) - .unwrap_err(); - - assert!(error.contains("setup claude-code")); - assert!( - !PluginLayout::new(PluginHost::ClaudeCode, dir.path()) - .marketplace_root - .exists() - ); - assert!( - runner - .commands() - .iter() - .any(|command| command == "/bin/claude plugin uninstall nemo-relay-plugin") - ); - assert!( - setup_runner - .calls() - .iter() - .any(|call| call == &format!("uninstall claude-code {DEFAULT_GATEWAY_URL}")) - ); -} - -#[test] -fn doctor_failure_fails_install_and_rolls_back() { - let dir = tempdir().unwrap(); - let runner = MockRunner::default() - .with_executable("nemo-relay", "/bin/nemo-relay") - .with_executable("claude", "/bin/claude"); - let setup_runner = MockSetupRunner { - failing_call: Some(format!("doctor claude-code {DEFAULT_GATEWAY_URL}")), - ..MockSetupRunner::default() - }; - let options = PluginInstallOptions { - skip_doctor: false, - ..options(dir.path()) - }; - - let error = install_host(PluginHost::ClaudeCode, &options, &runner, &setup_runner).unwrap_err(); - - assert!(error.contains("doctor claude-code")); - assert!( - !PluginLayout::new(PluginHost::ClaudeCode, dir.path()) - .marketplace_root - .exists() - ); -} - -#[test] -fn registration_failure_does_not_restore_plugin_setup_that_never_ran() { - let dir = tempdir().unwrap(); - let mut runner = MockRunner::default() - .with_executable("nemo-relay", "/bin/nemo-relay") - .with_executable("claude", "/bin/claude"); - runner.failing_suffix = Some("claude-code-marketplace".into()); - let setup_runner = MockSetupRunner::default(); - let install_dir = dir.path().join("failure"); - - let error = install_host( - PluginHost::ClaudeCode, - &options(&install_dir), - &runner, - &setup_runner, - ) - .unwrap_err(); - - assert!(error.contains("plugin marketplace add")); - assert!( - setup_runner.calls().is_empty(), - "setup rollback should not run before setup was attempted" - ); - assert!( - !PluginLayout::new(PluginHost::ClaudeCode, &install_dir) - .marketplace_root - .exists() - ); -} - -#[test] -fn plugin_registration_failure_rolls_back_marketplace_without_plugin_removal() { - let dir = tempdir().unwrap(); - let mut runner = MockRunner::default() - .with_executable("nemo-relay", "/bin/nemo-relay") - .with_executable("codex", "/bin/codex"); - runner.failing_suffix = Some("plugin add nemo-relay-plugin@nemo-relay-local".into()); - let setup_runner = MockSetupRunner::default(); - let layout = PluginLayout::new(PluginHost::Codex, dir.path()); - - let error = install_host( - PluginHost::Codex, - &options(dir.path()), - &runner, - &setup_runner, - ) - .unwrap_err(); - - assert!(error.contains("plugin add nemo-relay-plugin")); - assert!(!layout.marketplace_root.exists()); - assert!(!layout.state_path.exists()); - assert!( - runner - .commands() - .iter() - .any(|command| command.ends_with("plugin marketplace remove nemo-relay-local")) - ); - assert!( - runner - .commands() - .iter() - .all(|command| !command.contains("plugin remove nemo-relay-plugin")) - ); - assert!(setup_runner.calls().is_empty()); -} - -#[test] -fn state_write_failure_removes_generated_marketplace() { - let dir = tempdir().unwrap(); - let runner = MockRunner::default() - .with_executable("nemo-relay", "/bin/nemo-relay") - .with_executable("codex", "/bin/codex"); - let setup_runner = MockSetupRunner::default(); - let layout = PluginLayout::new(PluginHost::Codex, dir.path()); - std::fs::create_dir_all(&layout.state_path).unwrap(); - - let error = install_host( - PluginHost::Codex, - &options(dir.path()), - &runner, - &setup_runner, - ) - .unwrap_err(); - - assert!(error.contains("failed to write")); - assert!(!layout.marketplace_root.exists()); - assert!(layout.state_path.exists()); - assert!(runner.commands().is_empty()); - assert!(setup_runner.calls().is_empty()); -} - -#[test] -fn retry_after_partial_registration_rollback_does_not_restore_uninstalled_setup() { - let dir = tempdir().unwrap(); - let mut runner = MockRunner::default() - .with_executable("nemo-relay", "/bin/nemo-relay") - .with_executable("codex", "/bin/codex"); - runner.failing_suffixes = vec![ - "plugin add nemo-relay-plugin@nemo-relay-local".into(), - "plugin marketplace remove nemo-relay-local".into(), - ]; - let setup_runner = MockSetupRunner::default(); - - let error = install_host( - PluginHost::Codex, - &options(dir.path()), - &runner, - &setup_runner, - ) - .unwrap_err(); - - assert!(error.contains("additionally failed to roll back install")); - let state = read_state(PluginHost::Codex, dir.path()).unwrap(); - assert!(state.host_plugin_removed); - assert!(!state.host_marketplace_removed); - assert!(!state.plugin_setup_installed); - - let runner = MockRunner::default() - .with_executable("nemo-relay", "/bin/nemo-relay") - .with_executable("codex", "/bin/codex"); - uninstall_host( - PluginHost::Codex, - &options(dir.path()), - &runner, - &setup_runner, - ) - .unwrap(); - - assert!( - setup_runner.calls().is_empty(), - "retry cleanup must not restore provider/hooks setup that install never reached" - ); -} - -#[test] -fn retry_after_setup_attempted_rollback_restores_setup() { - let dir = tempdir().unwrap(); - let mut runner = MockRunner::default() - .with_executable("nemo-relay", "/bin/nemo-relay") - .with_executable("codex", "/bin/codex"); - runner.failing_suffix = Some("plugin marketplace remove nemo-relay-local".into()); - let setup_runner = MockSetupRunner { - failing_call: Some(format!("setup codex {DEFAULT_GATEWAY_URL}")), - ..MockSetupRunner::default() - }; - - let error = install_host( - PluginHost::Codex, - &options(dir.path()), - &runner, - &setup_runner, - ) - .unwrap_err(); - - assert!(error.contains("additionally failed to roll back install")); - let state = read_state(PluginHost::Codex, dir.path()).unwrap(); - assert!(state.host_plugin_removed); - assert!(!state.host_marketplace_removed); - assert!(state.plugin_setup_installed); - - let runner = MockRunner::default() - .with_executable("nemo-relay", "/bin/nemo-relay") - .with_executable("codex", "/bin/codex"); - uninstall_host( - PluginHost::Codex, - &options(dir.path()), - &runner, - &setup_runner, - ) - .unwrap(); - - assert!( - setup_runner - .calls() - .iter() - .any(|call| call == &format!("uninstall codex {DEFAULT_GATEWAY_URL}")) - ); -} - -#[test] -fn uninstall_uses_installed_state_and_removes_marketplace() { - let dir = tempdir().unwrap(); - let runner = MockRunner::default() - .with_executable("nemo-relay", "/bin/nemo-relay") - .with_executable("codex", "/bin/codex"); - let setup_runner = MockSetupRunner::default(); - install_host( - PluginHost::Codex, - &options(dir.path()), - &runner, - &setup_runner, - ) - .unwrap(); - let layout = PluginLayout::new(PluginHost::Codex, dir.path()); - assert!(layout.marketplace_root.exists()); - - uninstall_host( - PluginHost::Codex, - &options(dir.path()), - &runner, - &setup_runner, - ) - .unwrap(); - - assert!(!layout.marketplace_root.exists()); - assert!(!layout.state_path.exists()); - assert!( - setup_runner - .calls() - .iter() - .any(|call| call == &format!("uninstall codex {DEFAULT_GATEWAY_URL}")) - ); -} - -#[test] -fn uninstall_continues_when_relay_is_missing() { - let dir = tempdir().unwrap(); - let runner = MockRunner::default().with_executable("codex", "/bin/codex"); - let setup_runner = MockSetupRunner::default(); - let layout = PluginLayout::new(PluginHost::Codex, dir.path()); - write_installed_state(PluginHost::Codex, dir.path()); - - uninstall_host( - PluginHost::Codex, - &options(dir.path()), - &runner, - &setup_runner, - ) - .unwrap(); - - assert!(!layout.marketplace_root.exists()); - assert!(!layout.state_path.exists()); - assert!( - setup_runner - .calls() - .iter() - .any(|call| call == &format!("uninstall codex {DEFAULT_GATEWAY_URL}")) - ); -} - -#[test] -fn doctor_json_uses_quiet_plugin_report() { - let dir = tempdir().unwrap(); - let runner = MockRunner::default() - .with_executable("nemo-relay", "/bin/nemo-relay") - .with_executable("codex", "/bin/codex") - .with_capture_output( - "/bin/codex plugin list", - "PLUGIN STATUS VERSION PATH\n\ - nemo-relay-plugin@nemo-relay-local installed, enabled 0.4.0 /tmp/nemo-relay-plugin\n", - ) - .with_capture_output( - "/bin/codex plugin marketplace list", - "MARKETPLACE ROOT\nnemo-relay-local /tmp/nemo-relay-local\n", - ); - let setup_runner = MockSetupRunner::default(); - let options = options(dir.path()); - write_installed_state(PluginHost::Codex, dir.path()); - - let report = - doctor_host_json_value(PluginHost::Codex, &options, &runner, &setup_runner).unwrap(); - - assert_eq!( - setup_runner.calls(), - vec![format!("doctor-json codex {DEFAULT_GATEWAY_URL}")] - ); - assert_eq!(report["host"], json!("codex")); - assert_eq!(report["ok"], json!(true)); - assert_eq!(report["host_registration"]["ok"], json!(true)); - assert_eq!( - runner.capture_commands(), - vec![ - "/bin/codex plugin list", - "/bin/codex plugin marketplace list" - ] - ); -} - -#[test] -fn readiness_report_marks_missing_generated_plugin_files_as_failed() { - let dir = tempdir().unwrap(); - let runner = MockRunner::default() - .with_executable("nemo-relay", "/bin/nemo-relay") - .with_executable("codex", "/bin/codex") - .with_capture_output( - "/bin/codex plugin list", - "nemo-relay-plugin@nemo-relay-local installed, enabled\n", - ) - .with_capture_output( - "/bin/codex plugin marketplace list", - "nemo-relay-local /tmp/nemo-relay-local\n", - ); - let setup_runner = MockSetupRunner::default(); - let options = options(dir.path()); - write_installed_state(PluginHost::Codex, dir.path()); - let layout = PluginLayout::new(PluginHost::Codex, dir.path()); - std::fs::remove_file(layout.plugin_manifest).unwrap(); - - let report = collect_host_plugin_readiness(PluginHost::Codex, &options, &runner, &setup_runner); - - assert!(!report.ok()); - assert!(report.checks.iter().any(|check| { - check.name == "Generated plugin" && !check.ok && check.details.contains("missing") - })); - assert_eq!( - setup_runner.calls(), - vec![format!("doctor-json codex {DEFAULT_GATEWAY_URL}")] - ); -} - -#[test] -fn readiness_report_rejects_invalid_generated_manifest_contents() { - let dir = tempdir().unwrap(); - let runner = MockRunner::default() - .with_executable("nemo-relay", "/bin/nemo-relay") - .with_executable("codex", "/bin/codex") - .with_capture_output( - "/bin/codex plugin list", - "nemo-relay-plugin@nemo-relay-local installed, enabled\n", - ) - .with_capture_output( - "/bin/codex plugin marketplace list", - "nemo-relay-local /tmp/nemo-relay-local\n", - ); - let setup_runner = MockSetupRunner::default(); - let options = options(dir.path()); - write_installed_state(PluginHost::Codex, dir.path()); - let layout = PluginLayout::new(PluginHost::Codex, dir.path()); - std::fs::write( - &layout.marketplace_manifest, - r#"{"name":"wrong-marketplace"}"#, - ) - .unwrap(); - - let report = collect_host_plugin_readiness(PluginHost::Codex, &options, &runner, &setup_runner); - - assert!(!report.ok()); - assert!(report.checks.iter().any(|check| { - check.name == "Generated marketplace" && !check.ok && check.details.contains("unexpected") - })); -} - -#[test] -fn readiness_report_accepts_generated_plugin_manifest_from_an_older_version() { - let dir = tempdir().unwrap(); - let runner = MockRunner::default() - .with_executable("nemo-relay", "/bin/nemo-relay") - .with_executable("codex", "/bin/codex") - .with_capture_output( - "/bin/codex plugin list", - "nemo-relay-plugin@nemo-relay-local installed, enabled\n", - ) - .with_capture_output( - "/bin/codex plugin marketplace list", - "nemo-relay-local /tmp/nemo-relay-local\n", - ); - let setup_runner = MockSetupRunner::default(); - let options = options(dir.path()); - write_installed_state(PluginHost::Codex, dir.path()); - let layout = PluginLayout::new(PluginHost::Codex, dir.path()); - let mut manifest = plugin_manifest(PluginHost::Codex); - manifest["version"] = json!("0.0.0"); - std::fs::write( - &layout.plugin_manifest, - serde_json::to_vec(&manifest).unwrap(), - ) - .unwrap(); - - let report = collect_host_plugin_readiness(PluginHost::Codex, &options, &runner, &setup_runner); - - assert!(report.ok()); - assert!( - report - .checks - .iter() - .any(|check| check.name == "Generated plugin" && check.ok) - ); -} - -#[test] -fn doctor_json_preserves_unknown_host_registration_state() { - let dir = tempdir().unwrap(); - let setup_runner = MockSetupRunner::default(); - let options = options(dir.path()); - write_installed_state(PluginHost::Codex, dir.path()); - - let report = doctor_host_json_value( - PluginHost::Codex, - &options, - &MockRunner::default(), - &setup_runner, - ) - .unwrap(); - - assert_eq!(report["host_registration"]["ok"], json!(false)); - assert!(report["host_registration"]["host_plugin_registered"].is_null()); - assert!(report["host_registration"]["host_marketplace_registered"].is_null()); -} - -#[test] -fn timed_out_host_plugin_readiness_is_actionable() { - let state_path = PathBuf::from("/tmp/nemo-relay/codex.json"); - let (sender, receiver) = mpsc::sync_channel(1); - let _sender = sender; - - let report = receive_host_plugin_readiness( - PendingHostPluginReadiness { - host: PluginHost::Codex, - state_path: state_path.clone(), - receiver, - }, - Duration::ZERO, - ); - - assert!(!report.ok()); - assert_eq!(report.state_path, state_path); - assert_eq!(report.remediation, "nemo-relay install codex --force"); - assert!( - report - .checks - .iter() - .any(|check| check.name == "Host readiness" && !check.ok) - ); -} - -#[test] -fn stopped_lazy_sidecar_does_not_fail_host_readiness() { - let mut readiness = HostPluginReadiness { - host: "codex".into(), - remediation: "nemo-relay install codex --force".into(), - state_path: PathBuf::from("/tmp/codex.json"), - marketplace: None, - plugin: None, - checks: vec![], - relay: None, - host_plugin_registered: None, - host_marketplace_registered: None, - plugin_setup: None, - }; - - append_plugin_setup_checks( - &mut readiness, - &json!({ - "sidecar_health": "not_running_lazy_start", - "checks": { - "plugin_binary": true, - "sidecar_running": false, - "codex_provider_alias": true, - "codex_hooks": true - } - }), - ); - - assert!(readiness.ok()); - assert!( - readiness - .checks - .iter() - .any(|check| check.name == "Sidecar health") - ); - assert!( - !readiness - .checks - .iter() - .any(|check| check.name == "sidecar running") - ); -} - -#[test] -fn doctor_validates_claude_host_registration_before_setup_doctor() { - let dir = tempdir().unwrap(); - let runner = MockRunner::default() - .with_executable("nemo-relay", "/bin/nemo-relay") - .with_executable("claude", "/bin/claude") - .with_capture_output( - "/bin/claude plugin list --json", - json!([ - { "id": "nemo-relay-plugin@nemo-relay-local" } - ]) - .to_string(), - ) - .with_capture_output( - "/bin/claude plugin marketplace list --json", - json!([ - { "name": "nemo-relay-local" } - ]) - .to_string(), - ); - let setup_runner = MockSetupRunner::default(); - let options = options(dir.path()); - write_installed_state(PluginHost::ClaudeCode, dir.path()); - - doctor_host(PluginHost::ClaudeCode, &options, &runner, &setup_runner).unwrap(); - - assert_eq!( - setup_runner.calls(), - vec![format!("doctor-json claude-code {DEFAULT_GATEWAY_URL}")] - ); - assert_eq!( - runner.capture_commands(), - vec![ - "/bin/claude plugin list --json", - "/bin/claude plugin marketplace list --json" - ] - ); -} - -#[test] -fn doctor_fails_when_claude_host_plugin_is_missing() { - let dir = tempdir().unwrap(); - let runner = MockRunner::default() - .with_executable("nemo-relay", "/bin/nemo-relay") - .with_executable("claude", "/bin/claude") - .with_capture_output("/bin/claude plugin list --json", json!([]).to_string()) - .with_capture_output( - "/bin/claude plugin marketplace list --json", - json!([ - { "name": "nemo-relay-local" } - ]) - .to_string(), - ); - let setup_runner = MockSetupRunner::default(); - let options = options(dir.path()); - write_installed_state(PluginHost::ClaudeCode, dir.path()); - - let error = doctor_host(PluginHost::ClaudeCode, &options, &runner, &setup_runner).unwrap_err(); - - assert!(error.contains("nemo-relay install claude-code --force")); - assert_eq!( - setup_runner.calls(), - vec![format!("doctor-json claude-code {DEFAULT_GATEWAY_URL}")] - ); -} - -#[test] -fn doctor_fails_when_claude_host_marketplace_is_missing() { - let dir = tempdir().unwrap(); - let runner = MockRunner::default() - .with_executable("nemo-relay", "/bin/nemo-relay") - .with_executable("claude", "/bin/claude") - .with_capture_output( - "/bin/claude plugin list --json", - json!([ - { "id": "nemo-relay-plugin@nemo-relay-local" } - ]) - .to_string(), - ) - .with_capture_output( - "/bin/claude plugin marketplace list --json", - json!([]).to_string(), - ); - let setup_runner = MockSetupRunner::default(); - let options = options(dir.path()); - write_installed_state(PluginHost::ClaudeCode, dir.path()); - - let error = doctor_host(PluginHost::ClaudeCode, &options, &runner, &setup_runner).unwrap_err(); - - assert!(error.contains("nemo-relay install claude-code --force")); - assert_eq!( - setup_runner.calls(), - vec![format!("doctor-json claude-code {DEFAULT_GATEWAY_URL}")] - ); -} - -#[test] -fn uninstall_host_failure_does_not_restore_plugin_setup() { - let dir = tempdir().unwrap(); - let mut runner = MockRunner::default() - .with_executable("nemo-relay", "/bin/nemo-relay") - .with_executable("codex", "/bin/codex"); - runner.failing_suffix = Some("plugin remove nemo-relay-plugin@nemo-relay-local".into()); - let setup_runner = MockSetupRunner::default(); - - let error = uninstall_host( - PluginHost::Codex, - &options(dir.path()), - &runner, - &setup_runner, - ) - .unwrap_err(); - - assert!(error.contains("plugin remove")); - assert!( - setup_runner.calls().is_empty(), - "provider/hook setup should not be restored until host unregister succeeds" - ); -} - -#[test] -fn uninstall_records_host_removal_phases_before_plugin_restore() { - let dir = tempdir().unwrap(); - let runner = MockRunner::default() - .with_executable("nemo-relay", "/bin/nemo-relay") - .with_executable("codex", "/bin/codex"); - let setup_runner = MockSetupRunner { - failing_call: Some(format!("uninstall codex {DEFAULT_GATEWAY_URL}")), - ..MockSetupRunner::default() - }; - write_installed_state(PluginHost::Codex, dir.path()); - - let error = uninstall_host( - PluginHost::Codex, - &options(dir.path()), - &runner, - &setup_runner, - ) - .unwrap_err(); - - assert!(error.contains("uninstall codex")); - let state = read_state(PluginHost::Codex, dir.path()).unwrap(); - assert!(state.host_plugin_removed); - assert!(state.host_marketplace_removed); -} - -#[test] -fn uninstall_retry_skips_host_removal_after_prior_success() { - let dir = tempdir().unwrap(); - let mut runner = MockRunner::default().with_executable("nemo-relay", "/bin/nemo-relay"); - runner.failing_suffix = Some("plugin remove nemo-relay-plugin@nemo-relay-local".into()); - let setup_runner = MockSetupRunner::default(); - let layout = PluginLayout::new(PluginHost::Codex, dir.path()); - write_state_for_host( - PluginHost::Codex, - &PluginState { - marketplace_root: layout.marketplace_root.clone(), - plugin_root: layout.plugin_root.clone(), - host_plugin_removed: true, - host_marketplace_removed: true, - plugin_setup_installed: true, - }, - dir.path(), - &options(dir.path()), - ) - .unwrap(); - - uninstall_host( - PluginHost::Codex, - &options(dir.path()), - &runner, - &setup_runner, - ) - .unwrap(); - - assert!( - runner - .commands() - .iter() - .all(|command| !command.contains("plugin remove nemo-relay-plugin")) - ); - assert!( - setup_runner - .calls() - .iter() - .any(|call| call == &format!("uninstall codex {DEFAULT_GATEWAY_URL}")) - ); - assert!(!layout.state_path.exists()); -} - -#[test] -fn uninstall_retry_skips_plugin_removal_after_marketplace_failure() { - let dir = tempdir().unwrap(); - let mut runner = MockRunner::default() - .with_executable("nemo-relay", "/bin/nemo-relay") - .with_executable("codex", "/bin/codex"); - runner.failing_suffix = Some("plugin marketplace remove nemo-relay-local".into()); - let setup_runner = MockSetupRunner::default(); - let layout = PluginLayout::new(PluginHost::Codex, dir.path()); - write_state(&layout, &options(dir.path())).unwrap(); - - let error = uninstall_host( - PluginHost::Codex, - &options(dir.path()), - &runner, - &setup_runner, - ) - .unwrap_err(); - - assert!(error.contains("plugin marketplace remove")); - let state = read_state(PluginHost::Codex, dir.path()).unwrap(); - assert!(state.host_plugin_removed); - assert!(!state.host_marketplace_removed); - - let runner = MockRunner::default() - .with_executable("nemo-relay", "/bin/nemo-relay") - .with_executable("codex", "/bin/codex"); - uninstall_host( - PluginHost::Codex, - &options(dir.path()), - &runner, - &setup_runner, - ) - .unwrap(); - - assert!( - runner - .commands() - .iter() - .all(|command| !command.contains("plugin remove nemo-relay-plugin")) - ); - assert!(!layout.state_path.exists()); -} diff --git a/crates/cli/tests/coverage/plugin_shim_tests.rs b/crates/cli/tests/coverage/plugin_shim_tests.rs deleted file mode 100644 index 4ebbcbe19..000000000 --- a/crates/cli/tests/coverage/plugin_shim_tests.rs +++ /dev/null @@ -1,2201 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -use std::fs; -use std::io::{Read, Write}; -use std::net::TcpListener; -use std::sync::Mutex; -use std::thread; -use std::time::{Duration, Instant}; - -use serde_json::{Value, json}; -use tempfile::tempdir; - -use super::*; - -fn read_http_request(stream: &mut std::net::TcpStream) -> Vec { - stream - .set_read_timeout(Some(Duration::from_secs(2))) - .unwrap(); - let mut request = Vec::new(); - let mut buffer = [0_u8; 1024]; - loop { - match stream.read(&mut buffer) { - Ok(0) => break, - Ok(count) => { - request.extend_from_slice(&buffer[..count]); - if http_request_body_complete(&request) { - break; - } - } - Err(error) - if matches!( - error.kind(), - std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut - ) => - { - break; - } - Err(error) => panic!("failed to read local HTTP request: {error}"), - } - } - request -} - -fn http_request_body_complete(request: &[u8]) -> bool { - let Some(header_end) = request.windows(4).position(|window| window == b"\r\n\r\n") else { - return false; - }; - let body_start = header_end + 4; - let headers = String::from_utf8_lossy(&request[..body_start]); - let content_length = headers - .lines() - .find_map(|line| { - let (name, value) = line.split_once(':')?; - name.eq_ignore_ascii_case("content-length") - .then(|| value.trim().parse::().ok()) - .flatten() - }) - .unwrap_or(0); - request.len() >= body_start + content_length -} - -fn home_env_lock() -> &'static Mutex<()> { - &crate::test_support::ENV_TEST_LOCK -} - -struct HomeScope<'a> { - _guard: std::sync::MutexGuard<'a, ()>, - prev_home: Option, - prev_userprofile: Option, -} - -impl<'a> HomeScope<'a> { - fn enter(path: &std::path::Path) -> Self { - let guard = home_env_lock() - .lock() - .unwrap_or_else(|error| error.into_inner()); - let prev_home = std::env::var_os("HOME"); - let prev_userprofile = std::env::var_os("USERPROFILE"); - // SAFETY: This test holds a process-wide mutex for the lifetime of the env override. - unsafe { - std::env::set_var("HOME", path); - std::env::remove_var("USERPROFILE"); - } - Self { - _guard: guard, - prev_home, - prev_userprofile, - } - } -} - -impl<'a> Drop for HomeScope<'a> { - fn drop(&mut self) { - // SAFETY: This restores the process environment while the mutex is still held. - unsafe { - match self.prev_home.take() { - Some(value) => std::env::set_var("HOME", value), - None => std::env::remove_var("HOME"), - } - match self.prev_userprofile.take() { - Some(value) => std::env::set_var("USERPROFILE", value), - None => std::env::remove_var("USERPROFILE"), - } - } - } -} - -struct EnvVarGuard { - key: &'static str, - previous: Option, -} - -impl EnvVarGuard { - fn set_path(key: &'static str, value: &std::path::Path) -> Self { - let previous = std::env::var_os(key); - // SAFETY: Callers hold the process-wide environment mutex through HomeScope. - unsafe { - std::env::set_var(key, value); - } - Self { key, previous } - } - - fn set_value(key: &'static str, value: &str) -> Self { - let previous = std::env::var_os(key); - // SAFETY: Callers hold the process-wide environment mutex through HomeScope. - unsafe { - std::env::set_var(key, value); - } - Self { key, previous } - } - - fn remove(key: &'static str) -> Self { - let previous = std::env::var_os(key); - // SAFETY: Callers hold the process-wide environment mutex through HomeScope. - unsafe { - std::env::remove_var(key); - } - Self { key, previous } - } -} - -impl Drop for EnvVarGuard { - fn drop(&mut self) { - // SAFETY: This restores the process environment while HomeScope still holds the mutex. - unsafe { - match self.previous.take() { - Some(value) => std::env::set_var(self.key, value), - None => std::env::remove_var(self.key), - } - } - } -} - -#[test] -fn hook_with_io_defaults_blank_payload_and_writes_non_empty_response() { - let mut input = std::io::Cursor::new(b" \n\t".to_vec()); - let mut output = Vec::new(); - let ensured = std::cell::RefCell::new(Vec::new()); - let seen_payload = std::cell::RefCell::new(Vec::new()); - - let status = hook_with_io( - CodingAgent::Codex, - Some("http://127.0.0.1:59999"), - &mut input, - &mut output, - |agent, url| { - ensured - .borrow_mut() - .push((agent.as_arg().to_string(), url.to_string())); - }, - |agent, url, payload| { - assert_eq!(agent, CodingAgent::Codex); - assert_eq!(url, "http://127.0.0.1:59999"); - seen_payload.borrow_mut().extend_from_slice(payload); - Ok(br#"{"decision":"allow"}"#.to_vec()) - }, - || false, - ) - .unwrap(); - - assert_eq!(status, ExitCode::SUCCESS); - assert_eq!(&*seen_payload.borrow(), b"{}"); - assert_eq!(output, br#"{"decision":"allow"}"#); - assert_eq!( - ensured.into_inner(), - vec![("codex".to_string(), "http://127.0.0.1:59999".to_string())] - ); -} - -#[test] -fn hook_with_io_applies_fail_open_and_fail_closed_forwarding_policies() { - let mut input = std::io::Cursor::new(br#"{"event":"tool"}"#.to_vec()); - let mut output = Vec::new(); - let status = hook_with_io( - CodingAgent::ClaudeCode, - Some("http://127.0.0.1:59998"), - &mut input, - &mut output, - |_agent, _url| {}, - |_agent, _url, _payload| Err("forward failed open".to_string()), - || false, - ) - .unwrap(); - - assert_eq!(status, ExitCode::SUCCESS); - assert!(output.is_empty()); - - let mut input = std::io::Cursor::new(br#"{"event":"tool"}"#.to_vec()); - let mut output = Vec::new(); - let error = hook_with_io( - CodingAgent::ClaudeCode, - Some("http://127.0.0.1:59998"), - &mut input, - &mut output, - |_agent, _url| {}, - |_agent, _url, _payload| Err("forward failed closed".to_string()), - || true, - ) - .unwrap_err(); - - assert!(error.contains("forward failed closed")); - assert!(output.is_empty()); -} - -#[test] -fn backup_preserves_first_snapshot() { - let dir = tempdir().unwrap(); - let path = dir.path().join("config.toml"); - fs::write(&path, "model_provider = \"openai\"\n").unwrap(); - - backup(&path).unwrap(); - fs::write(&path, "model_provider = \"nemo-relay-openai\"\n").unwrap(); - backup(&path).unwrap(); - - assert_eq!( - fs::read_to_string(backup_path(&path)).unwrap(), - "model_provider = \"openai\"\n" - ); -} - -#[test] -fn atomic_write_replaces_existing_destination() { - let dir = tempdir().unwrap(); - let path = dir.path().join("config.toml"); - fs::write(&path, "old\n").unwrap(); - - atomic_write(&path, b"new\n").unwrap(); - - assert_eq!(fs::read_to_string(&path).unwrap(), "new\n"); -} - -#[test] -fn repeated_codex_install_does_not_overwrite_original_backup() { - let dir = tempdir().unwrap(); - let path = dir.path().join("config.toml"); - fs::write(&path, "model_provider = \"openai\"\n").unwrap(); - - install_codex_config(&path, DEFAULT_URL).unwrap(); - install_codex_config(&path, DEFAULT_URL).unwrap(); - - assert_eq!( - fs::read_to_string(backup_path(&path)).unwrap(), - "model_provider = \"openai\"\n" - ); -} - -#[test] -fn codex_install_backs_up_when_relay_provider_table_is_not_active() { - let dir = tempdir().unwrap(); - let path = dir.path().join("config.toml"); - fs::write( - &path, - r#" -model_provider = "openai" - -[model_providers.nemo-relay-openai] -name = "NeMo Relay" -base_url = "http://127.0.0.1:47632" -wire_api = "responses" -requires_openai_auth = true -supports_websockets = false -"#, - ) - .unwrap(); - - install_codex_config(&path, DEFAULT_URL).unwrap(); - - assert!( - fs::read_to_string(backup_path(&path)) - .unwrap() - .contains("model_provider = \"openai\"") - ); -} - -#[test] -fn codex_install_backs_up_when_hooks_flag_changes_even_with_managed_provider() { - let dir = tempdir().unwrap(); - let path = dir.path().join("config.toml"); - fs::write( - &path, - r#" -model_provider = "nemo-relay-openai" - -[features] -hooks = false - -[model_providers.nemo-relay-openai] -name = "NeMo Relay" -base_url = "http://127.0.0.1:47632" -wire_api = "responses" -requires_openai_auth = true -supports_websockets = false -"#, - ) - .unwrap(); - - install_codex_config(&path, DEFAULT_URL).unwrap(); - - let backup = fs::read_to_string(backup_path(&path)).unwrap(); - assert!(backup.contains("hooks = false")); - uninstall_codex_config(&path, DEFAULT_URL, false).unwrap(); - let updated = fs::read_to_string(&path).unwrap(); - assert!(updated.contains("hooks = false")); -} - -#[test] -fn codex_provider_installed_requires_active_managed_provider() { - let dir = tempdir().unwrap(); - let _home = HomeScope::enter(dir.path()); - let codex_dir = dir.path().join(".codex"); - fs::create_dir_all(&codex_dir).unwrap(); - let path = codex_dir.join("config.toml"); - fs::write( - &path, - r#" -model_provider = "openai" - -[model_providers.nemo-relay-openai] -name = "NeMo Relay" -base_url = "http://127.0.0.1:47632" -wire_api = "responses" -requires_openai_auth = true -supports_websockets = false -"#, - ) - .unwrap(); - - assert!(!codex_provider_installed(DEFAULT_URL)); - install_codex_config(&path, DEFAULT_URL).unwrap(); - assert!(codex_provider_installed(DEFAULT_URL)); - assert!(!codex_provider_installed("http://127.0.0.1:47633")); - fs::write( - &path, - r#" -model_provider = "nemo-relay-openai" - -[features] -hooks = false - -[model_providers.nemo-relay-openai] -name = "NeMo Relay" -base_url = "http://127.0.0.1:47632" -wire_api = "responses" -requires_openai_auth = true -supports_websockets = false -"#, - ) - .unwrap(); - assert!(!codex_provider_installed(DEFAULT_URL)); -} - -#[test] -fn codex_hooks_installed_requires_generated_plugin_local_groups() { - let dir = tempdir().unwrap(); - let _home = HomeScope::enter(dir.path()); - let codex_dir = dir.path().join(".codex"); - fs::create_dir_all(&codex_dir).unwrap(); - let path = codex_dir.join("hooks.json"); - fs::write( - &path, - serde_json::to_vec_pretty(&json!({ - "hooks": { - "SessionStart": [ - { - "hooks": [ - { - "type": "command", - "command": "nemo-relay plugin-shim hook codex --gateway-url http://127.0.0.1:47632", - "timeout": 30 - } - ] - } - ] - } - })) - .unwrap(), - ) - .unwrap(); - - assert!(!codex_hooks_installed(DEFAULT_URL).unwrap()); - install_codex_hooks(&path, DEFAULT_URL).unwrap(); - assert!(codex_hooks_installed(DEFAULT_URL).unwrap()); - assert!(!codex_hooks_installed("http://127.0.0.1:47633").unwrap()); -} - -#[test] -fn codex_doctor_allows_stopped_lazy_sidecar_when_static_setup_is_valid() { - let dir = tempdir().unwrap(); - let _home = HomeScope::enter(dir.path()); - let codex_dir = dir.path().join(".codex"); - fs::create_dir_all(&codex_dir).unwrap(); - install_codex_config(&codex_dir.join("config.toml"), DEFAULT_URL).unwrap(); - install_codex_hooks(&codex_dir.join("hooks.json"), DEFAULT_URL).unwrap(); - - let status = doctor(PluginShimDoctorCommand { - agent: CodingAgent::Codex, - gateway_url: DEFAULT_URL.into(), - }) - .unwrap(); - - assert_eq!(status, std::process::ExitCode::SUCCESS); -} - -#[test] -fn codex_doctor_requires_enabled_hooks_feature() { - let dir = tempdir().unwrap(); - let _home = HomeScope::enter(dir.path()); - let codex_dir = dir.path().join(".codex"); - fs::create_dir_all(&codex_dir).unwrap(); - fs::write( - codex_dir.join("config.toml"), - r#" -model_provider = "nemo-relay-openai" - -[features] -hooks = false - -[model_providers.nemo-relay-openai] -name = "NeMo Relay" -base_url = "http://127.0.0.1:47632" -wire_api = "responses" -requires_openai_auth = true -supports_websockets = false -"#, - ) - .unwrap(); - install_codex_hooks(&codex_dir.join("hooks.json"), DEFAULT_URL).unwrap(); - - let status = doctor(PluginShimDoctorCommand { - agent: CodingAgent::Codex, - gateway_url: DEFAULT_URL.into(), - }) - .unwrap(); - - assert_eq!(status, std::process::ExitCode::FAILURE); -} - -#[test] -fn plugin_shim_helpers_reject_unsupported_agents_and_report_lazy_claude_status() { - let dir = tempdir().unwrap(); - let _home = HomeScope::enter(dir.path()); - - assert!( - install(PluginShimInstallCommand { - agent: CodingAgent::ClaudeCode, - gateway_url: DEFAULT_URL.into(), - }) - .unwrap_err() - .contains("supports codex") - ); - assert!( - uninstall(PluginShimUninstallCommand { - agent: CodingAgent::ClaudeCode, - gateway_url: DEFAULT_URL.into(), - }) - .unwrap_err() - .contains("supports codex") - ); - assert!( - provider(PluginShimProviderCommand { - agent: CodingAgent::Codex, - action: PluginShimProviderAction::Status, - gateway_url: DEFAULT_URL.into(), - }) - .unwrap_err() - .contains("supports claude") - ); - assert!( - doctor_plugin(CodingAgent::Hermes, DEFAULT_URL) - .unwrap_err() - .contains("supports claude and codex") - ); - assert!( - doctor_plugin_json(CodingAgent::Hermes, DEFAULT_URL) - .unwrap_err() - .contains("supports claude and codex") - ); - - let report = doctor_plugin_json(CodingAgent::ClaudeCode, DEFAULT_URL).unwrap(); - assert_eq!(report["ok"], json!(false)); - assert_eq!(report["sidecar_health"], json!("not_running_lazy_start")); - assert_eq!(report["checks"]["claude_provider_routing"], json!(false)); -} - -#[test] -fn codex_setup_persists_path_based_launcher_when_sidecar_binary_override_is_set() { - let dir = tempdir().unwrap(); - let _home = HomeScope::enter(dir.path()); - let codex_dir = dir.path().join(".codex"); - fs::create_dir_all(&codex_dir).unwrap(); - let sidecar_override = dir.path().join("sidecar").join("nemo-relay"); - fs::create_dir_all(sidecar_override.parent().unwrap()).unwrap(); - fs::write(&sidecar_override, b"sidecar override").unwrap(); - let _binary_override = EnvVarGuard::set_path("NEMO_RELAY_PLUGIN_BINARY", &sidecar_override); - - install_codex(DEFAULT_URL).unwrap(); - - let hooks_path = codex_dir.join("hooks.json"); - let hooks: Value = serde_json::from_str(&fs::read_to_string(&hooks_path).unwrap()).unwrap(); - let launcher_command = codex_hook_command(DEFAULT_URL); - let sidecar_command = codex_hook_command_for_platform(&sidecar_override, DEFAULT_URL, false); - assert!(event_contains_command( - &hooks, - "SessionStart", - &launcher_command - )); - assert!(!event_contains_command( - &hooks, - "SessionStart", - &sidecar_command - )); - assert!(codex_hooks_installed(DEFAULT_URL).unwrap()); - assert_eq!( - doctor(PluginShimDoctorCommand { - agent: CodingAgent::Codex, - gateway_url: DEFAULT_URL.into(), - }) - .unwrap(), - std::process::ExitCode::SUCCESS - ); - - uninstall_codex(DEFAULT_URL).unwrap(); - let hooks: Value = serde_json::from_str(&fs::read_to_string(&hooks_path).unwrap()).unwrap(); - assert!(!event_contains_command( - &hooks, - "SessionStart", - &launcher_command - )); -} - -#[test] -fn relay_binary_prefers_sidecar_binary_override() { - let dir = tempdir().unwrap(); - let _home = HomeScope::enter(dir.path()); - let sidecar_override = dir.path().join("sidecar").join("nemo-relay"); - fs::create_dir_all(sidecar_override.parent().unwrap()).unwrap(); - fs::write(&sidecar_override, b"sidecar override").unwrap(); - let _binary_override = EnvVarGuard::set_path("NEMO_RELAY_PLUGIN_BINARY", &sidecar_override); - - assert_eq!(relay_binary().unwrap(), sidecar_override); -} - -#[test] -fn codex_uninstall_without_backup_removes_managed_hooks_flag() { - let dir = tempdir().unwrap(); - let path = dir.path().join("config.toml"); - fs::write( - &path, - r#" -model_provider = "nemo-relay-openai" - -[features] -hooks = true - -[model_providers.nemo-relay-openai] -name = "NeMo Relay" -base_url = "http://127.0.0.1:47632" -wire_api = "responses" -requires_openai_auth = true -supports_websockets = false -"#, - ) - .unwrap(); - - uninstall_codex_config(&path, DEFAULT_URL, false).unwrap(); - let updated = fs::read_to_string(&path).unwrap(); - - assert!(!updated.contains("model_provider")); - assert!(!updated.contains("nemo-relay-openai")); - assert!(!updated.contains("hooks = true")); -} - -#[test] -fn codex_uninstall_with_backup_preserves_user_changed_model_provider() { - let dir = tempdir().unwrap(); - let path = dir.path().join("config.toml"); - fs::write(&path, "model_provider = \"openai\"\n").unwrap(); - install_codex_config(&path, DEFAULT_URL).unwrap(); - fs::write( - &path, - r#" -model_provider = "local" - -[features] -hooks = true - -[model_providers.nemo-relay-openai] -name = "NeMo Relay" -base_url = "http://127.0.0.1:47632" -wire_api = "responses" -requires_openai_auth = true -supports_websockets = false -"#, - ) - .unwrap(); - - uninstall_codex_config(&path, DEFAULT_URL, false).unwrap(); - let updated = fs::read_to_string(&path).unwrap(); - - assert!(updated.contains("model_provider = \"local\"")); - assert!(!updated.contains("nemo-relay-openai")); - assert!(!backup_path(&path).exists()); -} - -#[test] -fn codex_uninstall_with_backup_preserves_user_changed_provider_table() { - let dir = tempdir().unwrap(); - let path = dir.path().join("config.toml"); - fs::write(&path, "model_provider = \"openai\"\n").unwrap(); - install_codex_config(&path, DEFAULT_URL).unwrap(); - fs::write( - &path, - r#" -model_provider = "nemo-relay-openai" - -[features] -hooks = true - -[model_providers.nemo-relay-openai] -name = "Custom Relay" -base_url = "http://127.0.0.1:47632" -wire_api = "responses" -requires_openai_auth = true -supports_websockets = false -"#, - ) - .unwrap(); - - uninstall_codex_config(&path, DEFAULT_URL, false).unwrap(); - let updated = fs::read_to_string(&path).unwrap(); - - assert!(updated.contains("model_provider = \"nemo-relay-openai\"")); - assert!(updated.contains("name = \"Custom Relay\"")); - assert!(updated.contains("nemo-relay-openai")); - assert!(!backup_path(&path).exists()); -} - -#[test] -fn codex_uninstall_preserves_user_changed_provider_url() { - let dir = tempdir().unwrap(); - let path = dir.path().join("config.toml"); - fs::write(&path, "model_provider = \"openai\"\n").unwrap(); - install_codex_config(&path, DEFAULT_URL).unwrap(); - fs::write( - &path, - r#" -model_provider = "nemo-relay-openai" - -[features] -hooks = true - -[model_providers.nemo-relay-openai] -name = "NeMo Relay" -base_url = "http://127.0.0.1:49999" -wire_api = "responses" -requires_openai_auth = true -supports_websockets = false -"#, - ) - .unwrap(); - - uninstall_codex_config(&path, DEFAULT_URL, false).unwrap(); - let updated = fs::read_to_string(&path).unwrap(); - - assert!(updated.contains("model_provider = \"nemo-relay-openai\"")); - assert!(updated.contains("base_url = \"http://127.0.0.1:49999\"")); - assert!(!backup_path(&path).exists()); -} - -#[test] -fn codex_uninstall_without_backup_preserves_user_changed_provider_url() { - let dir = tempdir().unwrap(); - let path = dir.path().join("config.toml"); - fs::write( - &path, - r#" -model_provider = "nemo-relay-openai" - -[features] -hooks = true - -[model_providers.nemo-relay-openai] -name = "NeMo Relay" -base_url = "http://127.0.0.1:49999" -wire_api = "responses" -requires_openai_auth = true -supports_websockets = false -"#, - ) - .unwrap(); - - uninstall_codex_config(&path, DEFAULT_URL, false).unwrap(); - let updated = fs::read_to_string(&path).unwrap(); - - assert!(updated.contains("model_provider = \"nemo-relay-openai\"")); - assert!(updated.contains("base_url = \"http://127.0.0.1:49999\"")); -} - -#[test] -fn codex_uninstall_without_backup_preserves_user_hooks_when_provider_is_not_managed() { - let dir = tempdir().unwrap(); - let path = dir.path().join("config.toml"); - fs::write( - &path, - r#" -model_provider = "openai" - -[features] -hooks = true -"#, - ) - .unwrap(); - - uninstall_codex_config(&path, DEFAULT_URL, false).unwrap(); - let updated = fs::read_to_string(&path).unwrap(); - - assert!(updated.contains("hooks = true")); -} - -#[test] -fn codex_uninstall_preserves_hooks_feature_when_user_hooks_remain() { - let dir = tempdir().unwrap(); - let _home = HomeScope::enter(dir.path()); - let codex_dir = dir.path().join(".codex"); - fs::create_dir_all(&codex_dir).unwrap(); - fs::write( - codex_dir.join("config.toml"), - r#" -model_provider = "openai" - -[features] -hooks = false -"#, - ) - .unwrap(); - - install_codex(DEFAULT_URL).unwrap(); - let hooks_path = codex_dir.join("hooks.json"); - let mut hooks: Value = serde_json::from_str(&fs::read_to_string(&hooks_path).unwrap()).unwrap(); - hooks["hooks"]["SessionStart"] - .as_array_mut() - .unwrap() - .push(json!({ - "hooks": [ - { - "type": "command", - "command": "custom-hook", - "timeout": 30 - } - ] - })); - fs::write(&hooks_path, serde_json::to_vec_pretty(&hooks).unwrap()).unwrap(); - - uninstall_codex(DEFAULT_URL).unwrap(); - - let updated_config = fs::read_to_string(codex_dir.join("config.toml")).unwrap(); - assert!(updated_config.contains("hooks = true")); - let updated_hooks: Value = - serde_json::from_str(&fs::read_to_string(&hooks_path).unwrap()).unwrap(); - assert!(event_contains_command( - &updated_hooks, - "SessionStart", - "custom-hook" - )); - assert!( - !serde_json::to_string(&updated_hooks) - .unwrap() - .contains("plugin-shim hook codex") - ); -} - -#[test] -fn codex_reinstall_uses_fresh_backup_after_prior_uninstall() { - let dir = tempdir().unwrap(); - let path = dir.path().join("config.toml"); - fs::write(&path, "model_provider = \"openai\"\n").unwrap(); - - install_codex_config(&path, DEFAULT_URL).unwrap(); - uninstall_codex_config(&path, DEFAULT_URL, false).unwrap(); - assert!(!backup_path(&path).exists()); - - fs::write(&path, "model_provider = \"local\"\n").unwrap(); - install_codex_config(&path, DEFAULT_URL).unwrap(); - uninstall_codex_config(&path, DEFAULT_URL, false).unwrap(); - - assert_eq!( - fs::read_to_string(&path).unwrap(), - "model_provider = \"local\"\n" - ); - assert!(!backup_path(&path).exists()); -} - -#[test] -fn claude_restore_without_backup_preserves_matching_user_relay_url() { - let dir = tempdir().unwrap(); - let _home = HomeScope::enter(dir.path()); - let settings = dir.path().join(".claude").join("settings.json"); - fs::create_dir_all(settings.parent().unwrap()).unwrap(); - fs::write( - &settings, - serde_json::to_vec_pretty(&json!({ - "env": { - "ANTHROPIC_BASE_URL": DEFAULT_URL, - "OTHER": "kept" - } - })) - .unwrap(), - ) - .unwrap(); - - claude_provider(PluginShimProviderAction::Restore, DEFAULT_URL).unwrap(); - - let updated: Value = serde_json::from_str(&fs::read_to_string(&settings).unwrap()).unwrap(); - assert_eq!( - json_env_string(&updated, "ANTHROPIC_BASE_URL"), - Some(DEFAULT_URL) - ); - assert_eq!(json_env_string(&updated, "OTHER"), Some("kept")); - assert!(!backup_path(&settings).exists()); -} - -#[test] -fn claude_enable_rolls_back_backup_when_settings_write_fails() { - let dir = tempdir().unwrap(); - let _home = HomeScope::enter(dir.path()); - let settings = dir.path().join(".claude").join("settings.json"); - fs::create_dir_all(settings.parent().unwrap()).unwrap(); - fs::write( - &settings, - serde_json::to_vec_pretty(&json!({ - "env": { - "ANTHROPIC_BASE_URL": "https://api.anthropic.com" - } - })) - .unwrap(), - ) - .unwrap(); - fs::create_dir(settings.with_extension("json.tmp")).unwrap(); - - let error = claude_provider(PluginShimProviderAction::Enable, DEFAULT_URL).unwrap_err(); - - assert!(error.contains("failed to write")); - assert!(!backup_path(&settings).exists()); -} - -#[test] -fn claude_enable_does_not_back_up_when_env_shape_is_invalid() { - let dir = tempdir().unwrap(); - let _home = HomeScope::enter(dir.path()); - let settings = dir.path().join(".claude").join("settings.json"); - fs::create_dir_all(settings.parent().unwrap()).unwrap(); - fs::write( - &settings, - serde_json::to_vec_pretty(&json!({ - "env": "invalid" - })) - .unwrap(), - ) - .unwrap(); - - let error = claude_provider(PluginShimProviderAction::Enable, DEFAULT_URL).unwrap_err(); - - assert!(error.contains("non-object env field")); - assert!(!backup_path(&settings).exists()); - let unchanged: Value = serde_json::from_str(&fs::read_to_string(&settings).unwrap()).unwrap(); - assert_eq!(unchanged["env"], json!("invalid")); -} - -#[test] -fn claude_restore_with_backup_preserves_user_settings_added_after_install() { - let dir = tempdir().unwrap(); - let _home = HomeScope::enter(dir.path()); - let settings = dir.path().join(".claude").join("settings.json"); - fs::create_dir_all(settings.parent().unwrap()).unwrap(); - fs::write( - &settings, - serde_json::to_vec_pretty(&json!({ - "env": { - "ANTHROPIC_BASE_URL": "https://api.anthropic.com", - "ORIGINAL": "kept" - } - })) - .unwrap(), - ) - .unwrap(); - claude_provider(PluginShimProviderAction::Enable, DEFAULT_URL).unwrap(); - fs::write( - &settings, - serde_json::to_vec_pretty(&json!({ - "env": { - "ANTHROPIC_BASE_URL": DEFAULT_URL, - "ORIGINAL": "updated", - "ADDED": "kept" - }, - "theme": "dark" - })) - .unwrap(), - ) - .unwrap(); - - claude_provider(PluginShimProviderAction::Restore, DEFAULT_URL).unwrap(); - - let updated: Value = serde_json::from_str(&fs::read_to_string(&settings).unwrap()).unwrap(); - assert_eq!( - json_env_string(&updated, "ANTHROPIC_BASE_URL"), - Some("https://api.anthropic.com") - ); - assert_eq!(json_env_string(&updated, "ORIGINAL"), Some("updated")); - assert_eq!(json_env_string(&updated, "ADDED"), Some("kept")); - assert_eq!(updated["theme"], json!("dark")); - assert!(!backup_path(&settings).exists()); -} - -#[test] -fn claude_restore_with_backup_preserves_user_changed_provider_url() { - let dir = tempdir().unwrap(); - let _home = HomeScope::enter(dir.path()); - let settings = dir.path().join(".claude").join("settings.json"); - fs::create_dir_all(settings.parent().unwrap()).unwrap(); - fs::write( - &settings, - serde_json::to_vec_pretty(&json!({ - "env": { - "ANTHROPIC_BASE_URL": "https://api.anthropic.com" - } - })) - .unwrap(), - ) - .unwrap(); - claude_provider(PluginShimProviderAction::Enable, DEFAULT_URL).unwrap(); - fs::write( - &settings, - serde_json::to_vec_pretty(&json!({ - "env": { - "ANTHROPIC_BASE_URL": "http://127.0.0.1:49999" - } - })) - .unwrap(), - ) - .unwrap(); - - claude_provider(PluginShimProviderAction::Restore, DEFAULT_URL).unwrap(); - - let updated: Value = serde_json::from_str(&fs::read_to_string(&settings).unwrap()).unwrap(); - assert_eq!( - json_env_string(&updated, "ANTHROPIC_BASE_URL"), - Some("http://127.0.0.1:49999") - ); - assert!(backup_path(&settings).exists()); -} - -#[test] -fn claude_reinstall_refreshes_backup_after_user_owned_restore() { - let dir = tempdir().unwrap(); - let _home = HomeScope::enter(dir.path()); - let settings = dir.path().join(".claude").join("settings.json"); - fs::create_dir_all(settings.parent().unwrap()).unwrap(); - fs::write( - &settings, - serde_json::to_vec_pretty(&json!({ - "env": { - "ANTHROPIC_BASE_URL": "https://api.anthropic.com" - } - })) - .unwrap(), - ) - .unwrap(); - - claude_provider(PluginShimProviderAction::Enable, DEFAULT_URL).unwrap(); - fs::write( - &settings, - serde_json::to_vec_pretty(&json!({ - "env": { - "ANTHROPIC_BASE_URL": "https://custom.example" - } - })) - .unwrap(), - ) - .unwrap(); - claude_provider(PluginShimProviderAction::Restore, DEFAULT_URL).unwrap(); - assert!(backup_path(&settings).exists()); - - claude_provider(PluginShimProviderAction::Enable, DEFAULT_URL).unwrap(); - let refreshed_backup: Value = - serde_json::from_str(&fs::read_to_string(backup_path(&settings)).unwrap()).unwrap(); - assert_eq!( - json_env_string(&refreshed_backup, "ANTHROPIC_BASE_URL"), - Some("https://custom.example") - ); - - claude_provider(PluginShimProviderAction::Restore, DEFAULT_URL).unwrap(); - - let updated: Value = serde_json::from_str(&fs::read_to_string(&settings).unwrap()).unwrap(); - assert_eq!( - json_env_string(&updated, "ANTHROPIC_BASE_URL"), - Some("https://custom.example") - ); - assert!(!backup_path(&settings).exists()); -} - -#[test] -fn claude_reinstall_uses_fresh_backup_after_prior_restore() { - let dir = tempdir().unwrap(); - let _home = HomeScope::enter(dir.path()); - let settings = dir.path().join(".claude").join("settings.json"); - fs::create_dir_all(settings.parent().unwrap()).unwrap(); - fs::write( - &settings, - serde_json::to_vec_pretty(&json!({ - "env": { - "ANTHROPIC_BASE_URL": "https://api.anthropic.com" - } - })) - .unwrap(), - ) - .unwrap(); - - claude_provider(PluginShimProviderAction::Enable, DEFAULT_URL).unwrap(); - claude_provider(PluginShimProviderAction::Restore, DEFAULT_URL).unwrap(); - assert!(!backup_path(&settings).exists()); - - fs::write( - &settings, - serde_json::to_vec_pretty(&json!({ - "env": { - "ANTHROPIC_BASE_URL": "https://custom.example" - } - })) - .unwrap(), - ) - .unwrap(); - - claude_provider(PluginShimProviderAction::Enable, DEFAULT_URL).unwrap(); - claude_provider(PluginShimProviderAction::Restore, DEFAULT_URL).unwrap(); - - let updated: Value = serde_json::from_str(&fs::read_to_string(&settings).unwrap()).unwrap(); - assert_eq!( - json_env_string(&updated, "ANTHROPIC_BASE_URL"), - Some("https://custom.example") - ); - assert!(!backup_path(&settings).exists()); -} - -#[test] -fn stale_lock_is_repaired_after_grace_period_even_when_pid_file_exists() { - let dir = tempdir().unwrap(); - let lock = dir.path().join("codex-sidecar.lock"); - fs::create_dir(&lock).unwrap(); - fs::write( - dir.path().join("codex-sidecar.pid"), - std::process::id().to_string(), - ) - .unwrap(); - - assert!(repair_stale_lock_after(&lock, Duration::ZERO)); - assert!(!lock.exists()); -} - -#[test] -fn sidecar_lock_name_uses_gateway_host_and_port() { - assert_eq!( - sidecar_lock_name("http://127.0.0.1:47632/hooks"), - "127.0.0.1-47632" - ); - assert_eq!(sidecar_lock_name("http://localhost"), "localhost-80"); - assert_eq!( - sidecar_lock_name("not a url/with spaces"), - "not_a_url_with_spaces" - ); -} - -#[test] -fn runtime_dir_fallback_is_user_scoped() { - let runtime = runtime_dir_for( - None, - None, - None, - std::path::PathBuf::from("/tmp"), - Some("alice/example".into()), - None, - ); - - assert_eq!( - runtime, - std::path::PathBuf::from("/tmp") - .join("alice_example") - .join("nemo-relay-plugin") - ); -} - -#[test] -fn runtime_dir_prefers_explicit_runtime_base_without_user_segment() { - let runtime = runtime_dir_for( - Some("/run/user/1000".into()), - None, - None, - std::path::PathBuf::from("/tmp"), - Some("alice".into()), - None, - ); - - assert_eq!( - runtime, - std::path::PathBuf::from("/run/user/1000").join("nemo-relay-plugin") - ); -} - -#[test] -fn codex_hook_command_uses_cmd_quoting_for_windows_paths() { - let relay = std::path::PathBuf::from(r"C:\Program Files\NeMo 100%\bin\nemo-relay.exe"); - let command = codex_hook_command_for_platform(&relay, DEFAULT_URL, true); - - assert_eq!( - command, - r#""C:\Program Files\NeMo 100%%\bin\nemo-relay.exe" plugin-shim hook codex --gateway-url http://127.0.0.1:47632"# - ); - assert_eq!( - shell_quote_arg_for_platform("foo&bar", true), - r#""foo^&bar""# - ); -} - -#[test] -fn codex_hook_command_uses_posix_single_quote_escaping() { - let relay = std::path::PathBuf::from("/tmp/NeMo $Relay`test'/bin/nemo-relay"); - let command = codex_hook_command_for_platform(&relay, DEFAULT_URL, false); - - assert_eq!( - command, - "'/tmp/NeMo $Relay`test'\\''/bin/nemo-relay' plugin-shim hook codex --gateway-url http://127.0.0.1:47632" - ); - assert_eq!(shell_quote_arg_for_platform("", false), "''"); - assert_eq!( - shell_quote_arg_for_platform(r"/tmp/path\with-backslash", false), - r#"'/tmp/path\with-backslash'"# - ); -} - -#[test] -fn hook_forward_connect_attempt_is_bounded() { - let error = post_hook(CodingAgent::Codex, "http://127.0.0.1:9", b"{}").unwrap_err(); - - assert!(error.contains("hook forward failed")); -} - -#[test] -fn hook_forward_posts_to_local_sidecar_and_healthz_accepts_200() { - let hook_listener = TcpListener::bind("127.0.0.1:0").unwrap(); - let hook_port = hook_listener.local_addr().unwrap().port(); - let hook_thread = thread::spawn(move || { - let (mut stream, _) = hook_listener.accept().unwrap(); - let request = read_http_request(&mut stream); - let raw = String::from_utf8_lossy(&request); - assert!(raw.starts_with("POST /hooks/codex HTTP/1.1")); - assert!(raw.contains("Content-Length: 7")); - stream - .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok") - .unwrap(); - }); - - let body = post_hook( - CodingAgent::Codex, - &format!("http://127.0.0.1:{hook_port}"), - br#"{"x":1}"#, - ) - .unwrap(); - assert_eq!(body, b"ok"); - hook_thread.join().unwrap(); - - let health_listener = TcpListener::bind("127.0.0.1:0").unwrap(); - let health_port = health_listener.local_addr().unwrap().port(); - let health_thread = thread::spawn(move || { - let (mut stream, _) = health_listener.accept().unwrap(); - let request = read_http_request(&mut stream); - assert!(String::from_utf8_lossy(&request).starts_with("GET /healthz")); - stream - .write_all(b"HTTP/1.1 200 OK\r\nConnection: close\r\n\r\n") - .unwrap(); - }); - assert!(healthz(&format!("http://127.0.0.1:{health_port}"))); - health_thread.join().unwrap(); -} - -#[test] -fn hook_http_response_requires_numeric_2xx_status() { - assert_eq!( - parse_http_response(b"HTTP/1.1 204 No Content\r\n\r\npayload").unwrap(), - b"payload" - ); - assert!( - parse_http_response(b"HTTP/1.1 500 upstream 2 bad\r\n\r\npayload") - .unwrap_err() - .contains("HTTP/1.1 500 upstream 2 bad") - ); - assert!( - parse_http_response(b"HTTP/1.1 OK 2\r\n\r\npayload") - .unwrap_err() - .contains("HTTP/1.1 OK 2") - ); -} - -#[test] -fn unready_sidecar_child_is_terminated_and_pid_removed() { - let dir = tempdir().unwrap(); - let pid_path = dir.path().join("codex-sidecar.pid"); - let mut command = long_lived_command(); - let child = command.spawn().unwrap(); - fs::write(&pid_path, child.id().to_string()).unwrap(); - - let error = terminate_unready_sidecar(child, &pid_path, DEFAULT_URL).unwrap_err(); - - assert!(error.contains("terminated startup process")); - assert!(!pid_path.exists()); -} - -#[test] -fn ensure_sidecar_releases_lock_when_startup_fails_fast() { - let dir = tempdir().unwrap(); - let _home = HomeScope::enter(dir.path()); - let runtime = dir.path().join("runtime"); - let _runtime = EnvVarGuard::set_path("XDG_RUNTIME_DIR", &runtime); - - ensure_sidecar(CodingAgent::Codex, "not a loopback url"); - - assert!( - !runtime - .join("nemo-relay-plugin") - .join("not_a_loopback_url-sidecar.lock") - .exists() - ); -} - -#[cfg(not(windows))] -#[test] -fn start_sidecar_reports_child_exit_before_healthz_ready() { - use std::os::unix::fs::PermissionsExt; - - let dir = tempdir().unwrap(); - let _home = HomeScope::enter(dir.path()); - let relay = dir.path().join("nemo-relay"); - fs::write(&relay, "#!/bin/sh\nexit 7\n").unwrap(); - let mut permissions = fs::metadata(&relay).unwrap().permissions(); - permissions.set_mode(0o755); - fs::set_permissions(&relay, permissions).unwrap(); - let _binary = EnvVarGuard::set_path("NEMO_RELAY_PLUGIN_BINARY", &relay); - - let listener = TcpListener::bind("127.0.0.1:0").unwrap(); - let port = listener.local_addr().unwrap().port(); - drop(listener); - - let error = start_sidecar( - CodingAgent::Codex, - &format!("http://127.0.0.1:{port}"), - dir.path(), - ) - .unwrap_err(); - - assert!(error.contains("exited before becoming ready")); - assert!(!dir.path().join("codex-sidecar.pid").exists()); -} - -#[test] -fn codex_uninstall_removes_only_exact_generated_hook_groups() { - let dir = tempdir().unwrap(); - let path = dir.path().join("hooks.json"); - let command = codex_hook_command("http://127.0.0.1:47633"); - let generated = generated_hooks(CodingAgent::Codex, &command); - let user_command = "custom-user-codex-hook"; - let config = json!({ - "hooks": { - "SessionStart": [ - generated["hooks"]["SessionStart"][0].clone(), - { - "hooks": [ - { - "type": "command", - "command": user_command, - "timeout": 30 - } - ] - } - ] - } - }); - fs::write(&path, serde_json::to_vec_pretty(&config).unwrap()).unwrap(); - - uninstall_codex_hooks(&path, "http://127.0.0.1:47633").unwrap(); - let updated: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap(); - - assert!(event_contains_command( - &updated, - "SessionStart", - user_command - )); - assert!(!generated_event_contains_group( - &updated, - "SessionStart", - &generated["hooks"]["SessionStart"][0] - )); -} - -#[test] -fn codex_install_hooks_removes_prior_non_default_generated_url() { - let dir = tempdir().unwrap(); - let path = dir.path().join("hooks.json"); - let old_command = codex_hook_command("http://127.0.0.1:47633"); - let new_command = codex_hook_command("http://127.0.0.1:47634"); - - install_codex_hooks(&path, "http://127.0.0.1:47633").unwrap(); - install_codex_hooks(&path, "http://127.0.0.1:47634").unwrap(); - let updated: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap(); - - assert!(!event_contains_command( - &updated, - "SessionStart", - &old_command - )); - assert!(event_contains_command( - &updated, - "SessionStart", - &new_command - )); -} - -#[test] -fn codex_uninstall_hooks_removes_all_generated_url_variants_for_launcher() { - let dir = tempdir().unwrap(); - let path = dir.path().join("hooks.json"); - let old_command = codex_hook_command("http://127.0.0.1:47633"); - let new_command = codex_hook_command("http://127.0.0.1:47634"); - let mut old_generated = generated_hooks(CodingAgent::Codex, &old_command); - let new_generated = generated_hooks(CodingAgent::Codex, &new_command); - old_generated["hooks"]["SessionStart"] - .as_array_mut() - .unwrap() - .push(new_generated["hooks"]["SessionStart"][0].clone()); - fs::write(&path, serde_json::to_vec_pretty(&old_generated).unwrap()).unwrap(); - - uninstall_codex_hooks(&path, "http://127.0.0.1:47634").unwrap(); - let updated: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap(); - - assert!(!event_contains_command( - &updated, - "SessionStart", - &old_command - )); - assert!(!event_contains_command( - &updated, - "SessionStart", - &new_command - )); -} - -#[cfg(windows)] -fn long_lived_command() -> std::process::Command { - let mut command = std::process::Command::new("cmd"); - command.args(["/C", "ping -n 60 127.0.0.1 >NUL"]); - command -} - -#[cfg(not(windows))] -fn long_lived_command() -> std::process::Command { - let mut command = std::process::Command::new("sh"); - command.args(["-c", "sleep 60"]); - command -} - -#[test] -fn codex_install_hooks_persist_custom_gateway_url() { - let dir = tempdir().unwrap(); - let path = dir.path().join("hooks.json"); - - install_codex_hooks(&path, "http://127.0.0.1:47633").unwrap(); - let updated: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap(); - let command = updated["hooks"]["SessionStart"][0]["hooks"][0]["command"] - .as_str() - .unwrap(); - - assert!(command.contains("plugin-shim hook codex")); - assert!(command.contains("--gateway-url http://127.0.0.1:47633")); -} - -#[test] -fn codex_install_hooks_replaces_legacy_generated_command() { - let dir = tempdir().unwrap(); - let path = dir.path().join("hooks.json"); - let relay = current_exe().unwrap(); - let legacy_command = legacy_codex_hook_command(&relay); - let legacy = generated_hooks(CodingAgent::Codex, &legacy_command); - fs::write(&path, serde_json::to_vec_pretty(&legacy).unwrap()).unwrap(); - - install_codex_hooks(&path, DEFAULT_URL).unwrap(); - let updated: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap(); - - assert!(!event_contains_command( - &updated, - "SessionStart", - &legacy_command - )); - assert!(event_contains_command( - &updated, - "SessionStart", - &codex_hook_command(DEFAULT_URL) - )); -} - -#[test] -fn codex_install_does_not_write_provider_config_when_hooks_are_invalid() { - let dir = tempdir().unwrap(); - let _home = HomeScope::enter(dir.path()); - let codex_dir = dir.path().join(".codex"); - fs::create_dir_all(&codex_dir).unwrap(); - fs::write( - codex_dir.join("config.toml"), - "model_provider = \"openai\"\n", - ) - .unwrap(); - fs::write(codex_dir.join("hooks.json"), "{ invalid json").unwrap(); - - let error = install_codex(DEFAULT_URL).unwrap_err(); - assert!(error.contains("invalid JSON")); - - assert_eq!( - fs::read_to_string(codex_dir.join("config.toml")).unwrap(), - "model_provider = \"openai\"\n" - ); - assert!(!backup_path(&codex_dir.join("config.toml")).exists()); -} - -#[test] -fn codex_install_does_not_write_hooks_when_config_is_invalid() { - let dir = tempdir().unwrap(); - let _home = HomeScope::enter(dir.path()); - let codex_dir = dir.path().join(".codex"); - fs::create_dir_all(&codex_dir).unwrap(); - fs::write(codex_dir.join("config.toml"), "model_provider = [").unwrap(); - let hooks_path = codex_dir.join("hooks.json"); - let original_hooks = serde_json::to_vec_pretty(&json!({ - "hooks": { - "SessionStart": [ - { - "hooks": [ - { - "type": "command", - "command": "custom-hook", - "timeout": 30 - } - ] - } - ] - } - })) - .unwrap(); - fs::write(&hooks_path, &original_hooks).unwrap(); - - let error = install_codex(DEFAULT_URL).unwrap_err(); - assert!(error.contains("invalid TOML")); - - assert_eq!(fs::read(&hooks_path).unwrap(), original_hooks); - assert!(!backup_path(&hooks_path).exists()); -} - -#[test] -fn codex_install_does_not_write_hooks_when_config_is_not_readable() { - let dir = tempdir().unwrap(); - let _home = HomeScope::enter(dir.path()); - let codex_dir = dir.path().join(".codex"); - fs::create_dir_all(&codex_dir).unwrap(); - fs::create_dir(codex_dir.join("config.toml")).unwrap(); - let hooks_path = codex_dir.join("hooks.json"); - let original_hooks = serde_json::to_vec_pretty(&json!({ - "hooks": { - "SessionStart": [ - { - "hooks": [ - { - "type": "command", - "command": "custom-hook", - "timeout": 30 - } - ] - } - ] - } - })) - .unwrap(); - fs::write(&hooks_path, &original_hooks).unwrap(); - - let error = install_codex(DEFAULT_URL).unwrap_err(); - assert!(error.contains("failed to read")); - - assert_eq!(fs::read(&hooks_path).unwrap(), original_hooks); - assert!(!backup_path(&hooks_path).exists()); -} - -#[test] -fn codex_install_config_rolls_back_backup_when_write_fails() { - let dir = tempdir().unwrap(); - let path = dir.path().join("config.toml"); - fs::write(&path, "model_provider = \"openai\"\n").unwrap(); - fs::create_dir(path.with_extension("toml.tmp")).unwrap(); - - let error = install_codex_config(&path, DEFAULT_URL).unwrap_err(); - - assert!(error.contains("failed to write")); - assert!(!backup_path(&path).exists()); -} - -#[test] -fn codex_install_rolls_back_hooks_backup_when_hook_merge_fails() { - let dir = tempdir().unwrap(); - let _home = HomeScope::enter(dir.path()); - let codex_dir = dir.path().join(".codex"); - fs::create_dir_all(&codex_dir).unwrap(); - fs::write( - codex_dir.join("config.toml"), - "model_provider = \"openai\"\n", - ) - .unwrap(); - let hooks_path = codex_dir.join("hooks.json"); - let original_hooks = serde_json::to_vec_pretty(&json!({ - "hooks": { - "SessionStart": "invalid" - } - })) - .unwrap(); - fs::write(&hooks_path, &original_hooks).unwrap(); - - let error = install_codex(DEFAULT_URL).unwrap_err(); - - assert!(error.contains("SessionStart hooks must be an array")); - assert_eq!(fs::read(&hooks_path).unwrap(), original_hooks); - assert!(!backup_path(&hooks_path).exists()); - assert_eq!( - fs::read_to_string(codex_dir.join("config.toml")).unwrap(), - "model_provider = \"openai\"\n" - ); -} - -#[test] -fn codex_uninstall_rolls_back_hooks_when_provider_config_is_invalid() { - let dir = tempdir().unwrap(); - let _home = HomeScope::enter(dir.path()); - let codex_dir = dir.path().join(".codex"); - fs::create_dir_all(&codex_dir).unwrap(); - fs::write(codex_dir.join("config.toml"), "model_provider = [").unwrap(); - let hooks_path = codex_dir.join("hooks.json"); - install_codex_hooks(&hooks_path, DEFAULT_URL).unwrap(); - let original_hooks = fs::read(&hooks_path).unwrap(); - - let error = uninstall_codex(DEFAULT_URL).unwrap_err(); - - assert!(error.contains("invalid TOML")); - assert_eq!(fs::read(&hooks_path).unwrap(), original_hooks); -} - -#[test] -fn codex_install_rolls_back_hooks_when_provider_config_write_fails() { - let dir = tempdir().unwrap(); - let _home = HomeScope::enter(dir.path()); - let codex_dir = dir.path().join(".codex"); - fs::create_dir_all(&codex_dir).unwrap(); - fs::write( - codex_dir.join("config.toml"), - "model_provider = \"openai\"\n", - ) - .unwrap(); - fs::create_dir(codex_dir.join("config.toml.tmp")).unwrap(); - let hooks_path = codex_dir.join("hooks.json"); - let original_hooks = serde_json::to_vec_pretty(&json!({ - "hooks": { - "SessionStart": [ - { - "hooks": [ - { - "type": "command", - "command": "custom-hook", - "timeout": 30 - } - ] - } - ] - } - })) - .unwrap(); - fs::write(&hooks_path, &original_hooks).unwrap(); - - let error = install_codex(DEFAULT_URL).unwrap_err(); - - assert!(error.contains("failed to write")); - assert_eq!(fs::read(&hooks_path).unwrap(), original_hooks); - assert!(!backup_path(&hooks_path).exists()); - assert_eq!( - fs::read_to_string(codex_dir.join("config.toml")).unwrap(), - "model_provider = \"openai\"\n" - ); -} - -#[test] -fn codex_uninstall_hooks_removes_legacy_generated_command() { - let dir = tempdir().unwrap(); - let path = dir.path().join("hooks.json"); - let relay = current_exe().unwrap(); - let legacy_command = legacy_codex_hook_command(&relay); - let legacy = generated_hooks(CodingAgent::Codex, &legacy_command); - fs::write(&path, serde_json::to_vec_pretty(&legacy).unwrap()).unwrap(); - - uninstall_codex_hooks(&path, DEFAULT_URL).unwrap(); - let updated: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap(); - - assert!(!event_contains_command( - &updated, - "SessionStart", - &legacy_command - )); -} - -#[test] -fn codex_provider_gateway_url_reads_managed_provider_url() { - let dir = tempdir().unwrap(); - let path = dir.path().join("config.toml"); - fs::write( - &path, - r#" -[model_providers.nemo-relay-openai] -base_url = "http://127.0.0.1:47633" -"#, - ) - .unwrap(); - - assert_eq!( - codex_provider_gateway_url(&path).as_deref(), - Some("http://127.0.0.1:47633") - ); -} - -#[test] -fn healthz_times_out_for_bad_port_occupant() { - let listener = TcpListener::bind("127.0.0.1:0").unwrap(); - let port = listener.local_addr().unwrap().port(); - let handle = thread::spawn(move || { - let Ok((mut stream, _)) = listener.accept() else { - return; - }; - thread::sleep(Duration::from_secs(2)); - let _ = stream.write_all(b"HTTP/1.1 200 OK\r\n\r\n"); - }); - - let started = Instant::now(); - assert!(!healthz(&format!("http://127.0.0.1:{port}"))); - assert!(started.elapsed() < Duration::from_secs(2)); - handle.join().unwrap(); -} - -#[test] -fn shared_json_helpers_cover_missing_invalid_and_non_object_inputs() { - let dir = tempdir().unwrap(); - let missing = dir.path().join("missing.json"); - assert_eq!(read_json_object(&missing).unwrap(), json!({})); - - let invalid = dir.path().join("invalid.json"); - fs::write(&invalid, "{not json").unwrap(); - assert!( - read_json_object(&invalid) - .unwrap_err() - .contains("invalid JSON") - ); - - let array = dir.path().join("array.json"); - fs::write(&array, "[]").unwrap(); - assert!( - read_json_object(&array) - .unwrap_err() - .contains("must contain a JSON object") - ); - - let nested = dir.path().join("nested").join("settings.json"); - write_json(&nested, &json!({"ok": true})).unwrap(); - assert_eq!( - fs::read_to_string(&nested).unwrap(), - "{\n \"ok\": true\n}\n" - ); -} - -#[test] -fn shared_filesystem_helpers_cover_tables_snapshots_and_lock_branches() { - let dir = tempdir().unwrap(); - let mut doc = "agent = \"codex\"\n" - .parse::() - .unwrap(); - ensure_table(&mut doc, "agent").insert("enabled", toml_edit::value(true)); - assert!(doc["agent"].is_table()); - assert_eq!(doc["agent"]["enabled"].as_bool(), Some(true)); - - let missing = dir.path().join("missing.txt"); - let snapshot = snapshot_optional_file(&missing).unwrap(); - fs::write(&missing, "created").unwrap(); - restore_file_snapshot(&snapshot).unwrap(); - assert!(!missing.exists()); - - let existing = dir.path().join("existing.txt"); - fs::write(&existing, "before").unwrap(); - let snapshot = snapshot_optional_file(&existing).unwrap(); - fs::write(&existing, "after").unwrap(); - restore_file_snapshot(&snapshot).unwrap(); - assert_eq!(fs::read_to_string(&existing).unwrap(), "before"); - - let lock = dir.path().join("lock"); - assert!(!repair_stale_lock_after(&lock, Duration::ZERO)); - fs::write(&lock, "not a directory").unwrap(); - assert!(!repair_stale_lock_after(&lock, Duration::ZERO)); - fs::remove_file(&lock).unwrap(); - fs::create_dir(&lock).unwrap(); - assert!(lock_is_old(&lock, Duration::ZERO)); - assert!(repair_stale_lock_after(&lock, Duration::ZERO)); - assert!(!lock.exists()); -} - -#[test] -fn shared_url_env_and_response_helpers_cover_error_branches() { - let dir = tempdir().unwrap(); - let _home = HomeScope::enter(dir.path()); - let _plugin_url = - EnvVarGuard::set_value("NEMO_RELAY_PLUGIN_GATEWAY_URL", "http://127.0.0.1:47640"); - let _claude_url = EnvVarGuard::set_value("NEMO_RELAY_GATEWAY_URL", "http://127.0.0.1:47641"); - let _timeout = EnvVarGuard::set_value("NEMO_RELAY_PLUGIN_IDLE_TIMEOUT_SECS", "7"); - let _fail_closed = EnvVarGuard::set_value("NEMO_RELAY_FAIL_CLOSED", "1"); - - assert_eq!( - gateway_url(CodingAgent::Codex, None), - "http://127.0.0.1:47640" - ); - assert_eq!( - gateway_url(CodingAgent::ClaudeCode, None), - "http://127.0.0.1:47641" - ); - assert_eq!( - gateway_url(CodingAgent::Codex, Some("http://127.0.0.1:9")), - "http://127.0.0.1:9" - ); - assert_eq!(plugin_idle_timeout(), "7"); - assert!(fail_closed()); - - assert_eq!( - runtime_dir_for( - Some("/run/user/1000".into()), - Some("/tmp/ignored".into()), - None, - dir.path().join("tmp"), - Some("ignored".into()), - None, - ), - std::path::PathBuf::from("/run/user/1000").join("nemo-relay-plugin") - ); - assert_eq!( - runtime_dir_for( - None, - None, - None, - dir.path().join("tmp"), - Some("user/name".into()), - None, - ), - dir.path() - .join("tmp") - .join("user_name") - .join("nemo-relay-plugin") - ); - assert_eq!( - sidecar_lock_name("http://localhost:47632/hooks"), - "localhost-47632" - ); - assert_eq!(sidecar_lock_name("not a url!*"), "not_a_url__"); - - assert_eq!( - parse_loopback_url("http://localhost:47632/path").unwrap(), - ("localhost".to_string(), 47632) - ); - assert!( - parse_loopback_url("https://127.0.0.1:47632") - .unwrap_err() - .contains("http loopback") - ); - assert!( - parse_loopback_url("http://192.168.1.2:47632") - .unwrap_err() - .contains("loopback") - ); - assert!( - parse_loopback_url("http://127.0.0.1") - .unwrap_err() - .contains("missing port") - ); - assert!( - parse_loopback_url("http://127.0.0.1:nope") - .unwrap_err() - .contains("invalid gateway port") - ); - - assert_eq!( - parse_http_response(b"HTTP/1.1 204 No Content\r\nHeader: value\r\n\r\nbody").unwrap(), - b"body" - ); - assert!( - parse_http_response(b"HTTP/1.1 500 Server Error\r\n\r\nbad") - .unwrap_err() - .contains("HTTP/1.1 500") - ); - assert!( - parse_http_response(b"HTTP/1.1 200 OK\n\nbody") - .unwrap_err() - .contains("malformed") - ); -} - -#[test] -fn shared_defaults_cover_runtime_username_and_empty_segments() { - let dir = tempdir().unwrap(); - let _home = HomeScope::enter(dir.path()); - let _plugin_url = EnvVarGuard::remove("NEMO_RELAY_PLUGIN_GATEWAY_URL"); - let _claude_url = EnvVarGuard::remove("NEMO_RELAY_GATEWAY_URL"); - let _timeout = EnvVarGuard::remove("NEMO_RELAY_PLUGIN_IDLE_TIMEOUT_SECS"); - let _fail_closed = EnvVarGuard::remove("NEMO_RELAY_FAIL_CLOSED"); - - assert_eq!(gateway_url(CodingAgent::Codex, None), DEFAULT_URL); - assert_eq!(plugin_idle_timeout(), "300"); - assert!(!fail_closed()); - assert_eq!( - runtime_dir_for( - None, - None, - Some("/tmp/temp-base".into()), - dir.path().join("ignored"), - None, - Some("bob/name".into()), - ), - std::path::PathBuf::from("/tmp/temp-base").join("nemo-relay-plugin") - ); - assert_eq!(sidecar_lock_name(""), "unknown"); - assert_eq!( - runtime_dir_for(None, None, None, dir.path().into(), None, None), - dir.path().join("unknown-user").join("nemo-relay-plugin") - ); -} - -#[test] -fn relay_binary_rejects_missing_override_and_uses_current_exe_fallback() { - let dir = tempdir().unwrap(); - let _home = HomeScope::enter(dir.path()); - let missing = dir.path().join("missing-nemo-relay"); - let _binary_override = EnvVarGuard::set_path("NEMO_RELAY_PLUGIN_BINARY", &missing); - assert!( - relay_binary() - .unwrap_err() - .contains("NEMO_RELAY_PLUGIN_BINARY does not exist") - ); - drop(_binary_override); - let _binary_override = EnvVarGuard::remove("NEMO_RELAY_PLUGIN_BINARY"); - assert!(relay_binary().unwrap().exists()); -} - -#[test] -fn claude_provider_enable_status_and_restore_cover_managed_backup_paths() { - let dir = tempdir().unwrap(); - let _home = HomeScope::enter(dir.path()); - let settings_path = dir.path().join(".claude").join("settings.json"); - fs::create_dir_all(settings_path.parent().unwrap()).unwrap(); - fs::write( - &settings_path, - serde_json::to_vec_pretty(&json!({ - "env": { - "ANTHROPIC_BASE_URL": "https://api.anthropic.com", - "OTHER": "kept" - } - })) - .unwrap(), - ) - .unwrap(); - - assert_eq!(claude_settings_path().unwrap(), settings_path); - assert_eq!( - claude_settings_base_url().as_deref(), - Some("https://api.anthropic.com") - ); - claude_provider(PluginShimProviderAction::Enable, DEFAULT_URL).unwrap(); - assert_eq!(claude_settings_base_url().as_deref(), Some(DEFAULT_URL)); - assert_eq!( - json_env_string(&read_json_object(&settings_path).unwrap(), "OTHER"), - Some("kept") - ); - claude_provider(PluginShimProviderAction::Status, DEFAULT_URL).unwrap(); - claude_provider(PluginShimProviderAction::Restore, DEFAULT_URL).unwrap(); - assert_eq!( - claude_settings_base_url().as_deref(), - Some("https://api.anthropic.com") - ); - assert!(!backup_path(&settings_path).exists()); -} - -#[test] -fn claude_provider_restore_noops_without_matching_backup_or_managed_value() { - let dir = tempdir().unwrap(); - let _home = HomeScope::enter(dir.path()); - let settings_path = dir.path().join(".claude").join("settings.json"); - fs::create_dir_all(settings_path.parent().unwrap()).unwrap(); - fs::write( - &settings_path, - serde_json::to_vec_pretty(&json!({ - "env": { "ANTHROPIC_BASE_URL": "https://custom.example" } - })) - .unwrap(), - ) - .unwrap(); - - claude_provider(PluginShimProviderAction::Restore, DEFAULT_URL).unwrap(); - assert_eq!( - claude_settings_base_url().as_deref(), - Some("https://custom.example") - ); - - backup_claude_settings(&settings_path, false).unwrap(); - claude_provider(PluginShimProviderAction::Restore, DEFAULT_URL).unwrap(); - assert_eq!( - claude_settings_base_url().as_deref(), - Some("https://custom.example") - ); - assert!(backup_path(&settings_path).exists()); -} - -#[test] -fn claude_provider_errors_for_non_object_env_and_restore_env_type_mismatch() { - let dir = tempdir().unwrap(); - let _home = HomeScope::enter(dir.path()); - let settings_path = dir.path().join(".claude").join("settings.json"); - fs::create_dir_all(settings_path.parent().unwrap()).unwrap(); - fs::write(&settings_path, r#"{"env": "bad"}"#).unwrap(); - - assert!( - claude_provider(PluginShimProviderAction::Enable, DEFAULT_URL) - .unwrap_err() - .contains("non-object env field") - ); - - let mut value = json!("bad"); - assert!( - remove_json_env_string(&mut value, "ANTHROPIC_BASE_URL") - .unwrap_err() - .contains("must be a JSON object") - ); - let mut value = json!({"env": "bad"}); - assert!( - remove_json_env_string(&mut value, "ANTHROPIC_BASE_URL") - .unwrap_err() - .contains("env field") - ); - let mut value = json!({"env": "bad"}); - assert!( - restore_json_env_value( - &mut value, - &json!({"env": {"ANTHROPIC_BASE_URL": DEFAULT_URL}}), - "ANTHROPIC_BASE_URL", - ) - .unwrap_err() - .contains("env field") - ); -} - -#[test] -fn claude_backup_bootstraps_missing_settings_and_replaces_stale_backup() { - let dir = tempdir().unwrap(); - let settings_path = dir.path().join(".claude").join("settings.json"); - let backup = backup_path(&settings_path); - backup_claude_settings(&settings_path, false).unwrap(); - assert_eq!(fs::read_to_string(&backup).unwrap(), "{}\n"); - fs::write(&settings_path, r#"{"env":{"ANTHROPIC_BASE_URL":"new"}}"#).unwrap(); - backup_claude_settings(&settings_path, false).unwrap(); - assert_eq!(fs::read_to_string(&backup).unwrap(), "{}\n"); - backup_claude_settings(&settings_path, true).unwrap(); - assert!( - fs::read_to_string(&backup) - .unwrap() - .contains("ANTHROPIC_BASE_URL") - ); -} - -#[test] -fn plugin_shim_entrypoints_reject_unsupported_agents_and_report_json() { - let dir = tempdir().unwrap(); - let _home = HomeScope::enter(dir.path()); - let settings_path = dir.path().join(".claude").join("settings.json"); - fs::create_dir_all(settings_path.parent().unwrap()).unwrap(); - fs::write( - &settings_path, - serde_json::to_vec_pretty(&json!({ - "env": { "ANTHROPIC_BASE_URL": DEFAULT_URL } - })) - .unwrap(), - ) - .unwrap(); - - let report = doctor_plugin_json(CodingAgent::ClaudeCode, DEFAULT_URL).unwrap(); - assert_eq!(report["sidecar_health"], json!("not_running_lazy_start")); - assert_eq!(report["checks"]["claude_provider_routing"], json!(true)); - let codex_report = doctor_plugin_json(CodingAgent::Codex, DEFAULT_URL).unwrap(); - assert_eq!( - codex_report["sidecar_health"], - json!("not_running_lazy_start") - ); - assert_eq!(codex_report["checks"]["codex_provider_alias"], json!(false)); - assert_eq!(codex_report["checks"]["codex_hooks"], json!(false)); - assert!( - doctor_plugin_json(CodingAgent::Hermes, DEFAULT_URL) - .unwrap_err() - .contains("supports claude and codex") - ); - assert!( - doctor_plugin(CodingAgent::Hermes, DEFAULT_URL) - .unwrap_err() - .contains("supports claude and codex") - ); - assert!( - doctor_plugin(CodingAgent::Codex, DEFAULT_URL) - .unwrap_err() - .contains("codex plugin doctor checks failed") - ); - assert!( - install(PluginShimInstallCommand { - agent: CodingAgent::ClaudeCode, - gateway_url: DEFAULT_URL.into(), - }) - .unwrap_err() - .contains("supports codex") - ); - assert!( - uninstall(PluginShimUninstallCommand { - agent: CodingAgent::ClaudeCode, - gateway_url: DEFAULT_URL.into(), - }) - .unwrap_err() - .contains("supports codex") - ); - assert!( - provider(PluginShimProviderCommand { - agent: CodingAgent::Codex, - action: PluginShimProviderAction::Status, - gateway_url: DEFAULT_URL.into(), - }) - .unwrap_err() - .contains("supports claude") - ); - assert!( - post_hook(CodingAgent::Hermes, DEFAULT_URL, b"{}") - .unwrap_err() - .contains("supports claude and codex") - ); -} - -#[test] -fn plugin_shim_dispatcher_covers_supported_errors_and_serve_failure() { - let dir = tempdir().unwrap(); - let _home = HomeScope::enter(dir.path()); - let missing_relay = dir.path().join("missing-nemo-relay"); - let _binary_override = EnvVarGuard::set_path("NEMO_RELAY_PLUGIN_BINARY", &missing_relay); - - let error = run(PluginShimCommand { - command: PluginShimSubcommand::Serve(super::command::PluginShimServeCommand { - args: vec![], - }), - }) - .unwrap_err() - .to_string(); - assert!(error.contains("does not exist")); - - let error = run(PluginShimCommand { - command: PluginShimSubcommand::Install(PluginShimInstallCommand { - agent: CodingAgent::ClaudeCode, - gateway_url: DEFAULT_URL.into(), - }), - }) - .unwrap_err() - .to_string(); - assert!(error.contains("supports codex")); - - let error = run(PluginShimCommand { - command: PluginShimSubcommand::Uninstall(PluginShimUninstallCommand { - agent: CodingAgent::ClaudeCode, - gateway_url: DEFAULT_URL.into(), - }), - }) - .unwrap_err() - .to_string(); - assert!(error.contains("supports codex")); - - let error = run(PluginShimCommand { - command: PluginShimSubcommand::Provider(PluginShimProviderCommand { - agent: CodingAgent::Codex, - action: PluginShimProviderAction::Status, - gateway_url: DEFAULT_URL.into(), - }), - }) - .unwrap_err() - .to_string(); - assert!(error.contains("supports claude")); - - let error = run(PluginShimCommand { - command: PluginShimSubcommand::Doctor(PluginShimDoctorCommand { - agent: CodingAgent::Hermes, - gateway_url: DEFAULT_URL.into(), - }), - }) - .unwrap_err() - .to_string(); - assert!(error.contains("supports claude and codex")); -} - -#[test] -fn plugin_shim_dispatcher_covers_claude_provider_status_and_doctor() { - let dir = tempdir().unwrap(); - let _home = HomeScope::enter(dir.path()); - - run(PluginShimCommand { - command: PluginShimSubcommand::Provider(PluginShimProviderCommand { - agent: CodingAgent::ClaudeCode, - action: PluginShimProviderAction::Enable, - gateway_url: DEFAULT_URL.into(), - }), - }) - .unwrap(); - - assert_eq!( - run(PluginShimCommand { - command: PluginShimSubcommand::Provider(PluginShimProviderCommand { - agent: CodingAgent::ClaudeCode, - action: PluginShimProviderAction::Status, - gateway_url: DEFAULT_URL.into(), - }), - }) - .unwrap(), - std::process::ExitCode::SUCCESS - ); - assert_eq!( - run(PluginShimCommand { - command: PluginShimSubcommand::Doctor(PluginShimDoctorCommand { - agent: CodingAgent::ClaudeCode, - gateway_url: DEFAULT_URL.into(), - }), - }) - .unwrap(), - std::process::ExitCode::SUCCESS - ); - - run(PluginShimCommand { - command: PluginShimSubcommand::Provider(PluginShimProviderCommand { - agent: CodingAgent::ClaudeCode, - action: PluginShimProviderAction::Restore, - gateway_url: DEFAULT_URL.into(), - }), - }) - .unwrap(); -} - -fn event_contains_command(config: &Value, event: &str, command: &str) -> bool { - config - .get("hooks") - .and_then(Value::as_object) - .and_then(|hooks| hooks.get(event)) - .and_then(Value::as_array) - .is_some_and(|groups| { - groups.iter().any(|group| { - group - .get("hooks") - .and_then(Value::as_array) - .is_some_and(|hooks| { - hooks.iter().any(|hook| { - hook.get("command").and_then(Value::as_str) == Some(command) - }) - }) - }) - }) -} diff --git a/crates/cli/tests/coverage/shared/agent_process_tests.rs b/crates/cli/tests/coverage/shared/agent_process_tests.rs new file mode 100644 index 000000000..dfaba9b74 --- /dev/null +++ b/crates/cli/tests/coverage/shared/agent_process_tests.rs @@ -0,0 +1,344 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use super::*; + +#[cfg(unix)] +async fn wait_for_published_pid(path: &std::path::Path, process: &str) -> i32 { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + loop { + if let Some(pid) = std::fs::read_to_string(path) + .ok() + .and_then(|raw| raw.trim().parse::().ok()) + { + return pid; + } + assert!( + std::time::Instant::now() < deadline, + "{process} did not publish a complete PID" + ); + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + } +} + +#[test] +fn wrapper_probe_uses_last_host_token_and_validates_opaque_wrappers() { + assert_eq!( + version_probe_argv( + CodingAgent::Codex, + &command_argv("npm exec --package @openai/codex -- codex exec") + ), + [ + "npm", + "exec", + "--package", + "@openai/codex", + "--", + "codex", + "--version" + ] + ); + assert_eq!( + version_probe_argv( + CodingAgent::Codex, + &command_argv("custom-codex-wrapper --profile dev") + ), + ["custom-codex-wrapper", "--profile", "dev", "--version"] + ); + assert_eq!( + version_probe_argv(CodingAgent::Codex, &[]), + ["codex", "--version"] + ); +} + +#[test] +fn platform_resolution_supports_explicit_paths_and_windows_pathext() { + let temp = tempfile::tempdir().unwrap(); + let shim = temp.path().join("codex.CMD"); + std::fs::write(&shim, "").unwrap(); + + assert_eq!( + resolve_executable_for_platform( + "codex", + Some(temp.path().as_os_str()), + Some(std::ffi::OsStr::new(".EXE;.CMD")), + true, + ), + Some(shim.clone()) + ); + assert_eq!( + resolve_executable_for_platform( + shim.to_str().unwrap(), + None, + Some(std::ffi::OsStr::new(".EXE;.CMD")), + true, + ), + Some(shim) + ); + assert_eq!(resolve_executable_for_platform("", None, None, false), None); +} + +#[cfg(unix)] +#[tokio::test] +async fn supervised_wait_terminates_descendants_left_by_a_wrapper() { + let temp = tempfile::tempdir().unwrap(); + let descendant_pid_path = temp.path().join("descendant.pid"); + let argv = vec![ + "sh".into(), + "-c".into(), + "sleep 30 & echo $! > \"$1\"; exit 0".into(), + "sh".into(), + descendant_pid_path.display().to_string(), + ]; + let mut command = tokio_command(&argv); + let mut child = SupervisedChild::spawn(&mut command).await.unwrap(); + + let status = tokio::time::timeout(std::time::Duration::from_secs(5), child.wait()) + .await + .expect("wrapper did not exit") + .unwrap(); + + assert!(status.success()); + let pid = wait_for_published_pid(&descendant_pid_path, "wrapper descendant").await; + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + loop { + // SAFETY: Signal 0 performs an existence check and does not alter the target process. + let result = unsafe { libc::kill(pid, 0) }; + if result == -1 && std::io::Error::last_os_error().raw_os_error() == Some(libc::ESRCH) { + break; + } + assert!( + std::time::Instant::now() < deadline, + "wrapper descendant {pid} survived normal wrapper exit" + ); + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + } +} + +#[cfg(unix)] +#[tokio::test] +async fn supervision_error_terminates_and_reaps_the_child_tree_first() { + let temp = tempfile::tempdir().unwrap(); + let child_pid_path = temp.path().join("child.pid"); + let argv = vec![ + "sh".into(), + "-c".into(), + "echo $$ > \"$1\"; exec sleep 30".into(), + "sh".into(), + child_pid_path.display().to_string(), + ]; + let mut command = tokio_command(&argv); + let mut child = SupervisedChild::spawn(&mut command).await.unwrap(); + let pid = wait_for_published_pid(&child_pid_path, "supervised child").await; + + let error = child + .inject_wait_error_for_test(std::io::Error::other("injected wait failure")) + .await + .unwrap_err(); + + assert!(error.to_string().contains("injected wait failure")); + // SAFETY: Signal zero only checks whether the reaped test child still exists. + assert_eq!(unsafe { libc::kill(pid, 0) }, -1); + assert_eq!( + std::io::Error::last_os_error().raw_os_error(), + Some(libc::ESRCH) + ); +} + +#[cfg(windows)] +#[tokio::test] +async fn windows_supervision_assigns_before_a_wrapper_can_spawn_a_descendant() { + let temp = tempfile::tempdir().unwrap(); + let descendant_pid_path = temp.path().join("descendant.pid"); + let release_path = temp.path().join("release-wrapper"); + let wrapper = temp.path().join("spawn-descendant.ps1"); + std::fs::write( + &wrapper, + r#"$ErrorActionPreference = 'Stop' +$start = [System.Diagnostics.ProcessStartInfo]::new() +$start.FileName = (Get-Process -Id $PID).Path +$start.Arguments = '-NoProfile -NonInteractive -Command "Start-Sleep -Seconds 30"' +$start.UseShellExecute = $false +$start.CreateNoWindow = $true +$start.RedirectStandardInput = $true +$start.RedirectStandardOutput = $true +$start.RedirectStandardError = $true +$child = [System.Diagnostics.Process]::Start($start) +Set-Content -LiteralPath $args[0] -Value $child.Id -Encoding ASCII -NoNewline +$deadline = [DateTime]::UtcNow.AddSeconds(15) +while (-not (Test-Path -LiteralPath $args[1])) { + if ([DateTime]::UtcNow -ge $deadline) { + throw 'Relay test did not release the wrapper' + } + Start-Sleep -Milliseconds 20 +} +"#, + ) + .unwrap(); + let argv = vec![ + "powershell.exe".into(), + "-NoProfile".into(), + "-NonInteractive".into(), + "-ExecutionPolicy".into(), + "Bypass".into(), + "-File".into(), + wrapper.display().to_string(), + descendant_pid_path.display().to_string(), + release_path.display().to_string(), + ]; + let mut command = tokio_command(&argv); + command + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()); + let mut child = SupervisedChild::spawn(&mut command).await.unwrap(); + + let publish_deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + let descendant_pid = loop { + if let Some(process_id) = read_windows_process_id(&descendant_pid_path) { + break process_id; + } + if std::time::Instant::now() >= publish_deadline { + let _ = child.terminate().await; + panic!("PowerShell wrapper did not publish its child PID"); + } + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + }; + let descendant = match WindowsTestProcess::open(descendant_pid) { + Ok(descendant) => descendant, + Err(error) => { + let _ = child.terminate().await; + panic!("could not retain wrapper descendant {descendant_pid}: {error}"); + } + }; + std::fs::write(release_path, b"ready").unwrap(); + + let status = match tokio::time::timeout(std::time::Duration::from_secs(15), child.wait()).await + { + Ok(status) => status.unwrap(), + Err(_) => { + let _ = child.terminate().await; + panic!("PowerShell wrapper did not exit"); + } + }; + + assert!(status.success()); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + let escaped = loop { + if !descendant.is_active().unwrap() { + break false; + } + if std::time::Instant::now() >= deadline { + break true; + } + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + }; + if escaped { + descendant.terminate(); + } + assert!( + !escaped, + "wrapper descendant {descendant_pid} survived Job Object termination" + ); +} + +#[cfg(windows)] +fn read_windows_process_id(path: &std::path::Path) -> Option { + std::fs::read_to_string(path).ok()?.parse().ok() +} + +#[cfg(windows)] +struct WindowsTestProcess { + handle: windows_sys::Win32::Foundation::HANDLE, +} + +#[cfg(windows)] +impl WindowsTestProcess { + fn open(process_id: u32) -> std::io::Result { + use windows_sys::Win32::System::Threading::{ + OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, PROCESS_SYNCHRONIZE, PROCESS_TERMINATE, + }; + + // SAFETY: The wrapper published this live descendant PID while waiting for the test to + // release it. Holding this handle prevents PID reuse from redirecting later cleanup. + let handle = unsafe { + OpenProcess( + PROCESS_QUERY_LIMITED_INFORMATION | PROCESS_SYNCHRONIZE | PROCESS_TERMINATE, + 0, + process_id, + ) + }; + if handle.is_null() { + Err(std::io::Error::last_os_error()) + } else { + Ok(Self { handle }) + } + } + + fn is_active(&self) -> std::io::Result { + use windows_sys::Win32::Foundation::STILL_ACTIVE; + use windows_sys::Win32::System::Threading::GetExitCodeProcess; + + let mut exit_code = 0; + // SAFETY: `handle` remains live for this guard and `exit_code` is writable storage. + if unsafe { GetExitCodeProcess(self.handle, &mut exit_code) } == 0 { + Err(std::io::Error::last_os_error()) + } else { + Ok(exit_code == STILL_ACTIVE as u32) + } + } + + fn terminate(&self) { + use windows_sys::Win32::System::Threading::{TerminateProcess, WaitForSingleObject}; + + if matches!(self.is_active(), Ok(false)) { + return; + } + // SAFETY: `handle` identifies the original finite test descendant and was opened with + // termination and synchronization rights. + unsafe { + TerminateProcess(self.handle, 1); + WaitForSingleObject(self.handle, 5_000); + } + } +} + +#[cfg(windows)] +impl Drop for WindowsTestProcess { + fn drop(&mut self) { + use windows_sys::Win32::Foundation::CloseHandle; + + self.terminate(); + // SAFETY: The guard uniquely owns this handle and closes it exactly once. + unsafe { CloseHandle(self.handle) }; + } +} + +#[cfg(windows)] +#[test] +fn windows_command_shim_preserves_metacharacter_arguments() { + let temp = tempfile::tempdir().unwrap(); + let shim = temp.path().join("agent shim.cmd"); + let marker = temp.path().join("completed.txt"); + std::fs::write( + &shim, + "@echo off\r\n\ + @if not \"%~1\"==\"space & value\" exit /b 11\r\n\ + @if not \"%~2\"==\"caret^value\" exit /b 12\r\n\ + @if not \"%~3\"==\"%%TOKEN%%\" exit /b 13\r\n\ + @echo ok>\"%NEMO_RELAY_ARGV_MARKER%\"\r\n", + ) + .unwrap(); + let argv = vec![ + shim.display().to_string(), + "space & value".into(), + "caret^value".into(), + "%TOKEN%".into(), + ]; + let status = std_command(&argv) + .env("NEMO_RELAY_ARGV_MARKER", &marker) + .status() + .unwrap(); + assert!(status.success()); + assert_eq!(std::fs::read_to_string(marker).unwrap().trim(), "ok"); +} diff --git a/crates/cli/tests/coverage/banner_tests.rs b/crates/cli/tests/coverage/shared/banner_tests.rs similarity index 100% rename from crates/cli/tests/coverage/banner_tests.rs rename to crates/cli/tests/coverage/shared/banner_tests.rs diff --git a/crates/cli/tests/coverage/shared/bootstrap_state_tests.rs b/crates/cli/tests/coverage/shared/bootstrap_state_tests.rs new file mode 100644 index 000000000..c94580871 --- /dev/null +++ b/crates/cli/tests/coverage/shared/bootstrap_state_tests.rs @@ -0,0 +1,221 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use super::*; +use crate::test_support::{EnvScope, accept_bounded, header, read_headers}; +use std::ffi::OsStr; +use std::io::Write; +use std::net::TcpListener; + +#[test] +fn owner_records_are_versioned_endpoint_scoped_and_round_trip() { + let dir = tempfile::tempdir().unwrap(); + let url = "http://127.0.0.1:47632"; + let path = owner_path(dir.path(), url); + let record = OwnerRecord::new(42, url, "shutdown", Some("fingerprint")); + + write_owner_record(&path, &record).unwrap(); + + assert_eq!(read_owner_record(&path).unwrap(), Some(record.clone())); + assert!(record.valid_for(url)); + assert!(!record.valid_for("http://127.0.0.1:47633")); + assert!(owner_path(dir.path(), url).ends_with("sidecar-127.0.0.1-47632.owner.json")); + assert_eq!(lock_name("not a url/with spaces"), "not_a_url_with_spaces"); +} + +#[test] +fn recovery_records_preserve_pending_and_ready_attempts() { + let dir = tempfile::tempdir().unwrap(); + let url = "http://127.0.0.1:47632"; + let pending = RecoveryRecord { + from_instance: "first".into(), + endpoint_url: String::new(), + to_instance: String::new(), + }; + write_recovery(dir.path(), url, &pending).unwrap(); + assert_eq!(read_recovery(dir.path(), url).unwrap(), Some(pending)); + + let ready = RecoveryRecord { + from_instance: "first".into(), + endpoint_url: url.into(), + to_instance: "second".into(), + }; + write_recovery(dir.path(), url, &ready).unwrap(); + assert_eq!(read_recovery(dir.path(), url).unwrap(), Some(ready)); +} + +#[test] +fn startup_lock_serializes_competing_mcp_processes() { + let dir = tempfile::tempdir().unwrap(); + let url = "http://127.0.0.1:47632"; + let owner = lock_endpoint(dir.path(), url).unwrap(); + + let error = lock_endpoint_for(dir.path(), url, Duration::from_millis(25)).unwrap_err(); + assert!(error.contains("timed out waiting"), "{error}"); + + drop(owner); + lock_endpoint_for(dir.path(), url, Duration::from_millis(25)).unwrap(); +} + +#[test] +fn managed_owner_environment_is_validated_before_writing() { + let dir = tempfile::tempdir().unwrap(); + let relative = OsStr::new("relative"); + let absolute = dir.path().as_os_str(); + let address = "127.0.0.1:47632".parse().unwrap(); + + let _scope = EnvScope::set(&[ + (BOOTSTRAP_STATE_DIR_ENV, Some(relative)), + ( + "NEMO_RELAY_BOOTSTRAP_SHUTDOWN_TOKEN", + Some(OsStr::new("token")), + ), + ]); + let error = publish_owner_from_env(address, Some("token")).unwrap_err(); + assert!(error.contains("absolute path"), "{error}"); + drop(_scope); + + let _scope = EnvScope::set(&[ + (BOOTSTRAP_STATE_DIR_ENV, Some(absolute)), + ("NEMO_RELAY_BOOTSTRAP_SHUTDOWN_TOKEN", None), + ]); + let error = publish_owner_from_env(address, None).unwrap_err(); + assert!(error.contains("SHUTDOWN_TOKEN"), "{error}"); + drop(_scope); + + let _scope = EnvScope::set(&[ + (BOOTSTRAP_STATE_DIR_ENV, Some(absolute)), + ( + "NEMO_RELAY_BOOTSTRAP_SHUTDOWN_TOKEN", + Some(OsStr::new("token")), + ), + ]); + let error = + publish_owner_from_env("0.0.0.0:47632".parse().unwrap(), Some("token")).unwrap_err(); + assert!(error.contains("loopback"), "{error}"); +} + +#[test] +fn server_owner_guard_cleans_only_its_own_record() { + let dir = tempfile::tempdir().unwrap(); + let address = "127.0.0.1:47632".parse().unwrap(); + let _scope = EnvScope::set(&[ + (BOOTSTRAP_STATE_DIR_ENV, Some(dir.path().as_os_str())), + ( + "NEMO_RELAY_BOOTSTRAP_SHUTDOWN_TOKEN", + Some(OsStr::new("first-token")), + ), + ( + crate::configuration::BOOTSTRAP_FINGERPRINT_ENV, + Some(OsStr::new("fingerprint")), + ), + ]); + let guard = publish_owner_from_env(address, Some("first-token")) + .unwrap() + .unwrap(); + let path = owner_path(dir.path(), "http://127.0.0.1:47632"); + assert!(path.exists()); + + let replacement = OwnerRecord::new( + std::process::id(), + "http://127.0.0.1:47632", + "replacement-token", + Some("fingerprint"), + ); + write_owner_record(&path, &replacement).unwrap(); + drop(guard); + + assert_eq!(read_owner_record(&path).unwrap(), Some(replacement)); +} + +#[test] +fn stopping_an_absent_or_stale_owned_gateway_is_idempotent() { + let dir = tempfile::tempdir().unwrap(); + let config = dir.path().join("config"); + let _scope = EnvScope::set(&[ + ("XDG_CONFIG_HOME", Some(config.as_os_str())), + ("HOME", Some(dir.path().as_os_str())), + ("USERPROFILE", None), + ]); + let url = "http://127.0.0.1:9"; + + stop_owned_and_reset(url).unwrap(); + let state = state_dir().unwrap(); + create_private_dir(&state).unwrap(); + let path = owner_path(&state, url); + let owner = OwnerRecord::new(42, url, "shutdown", Some("fingerprint")); + write_owner_record(&path, &owner).unwrap(); + + stop_owned_and_reset(url).unwrap(); + assert!(!path.exists()); +} + +#[test] +fn authenticated_owned_gateway_is_shut_down_and_cleaned_up() { + let dir = tempfile::tempdir().unwrap(); + let config = dir.path().join("config"); + let _scope = EnvScope::set(&[ + ("XDG_CONFIG_HOME", Some(config.as_os_str())), + ("HOME", Some(dir.path().as_os_str())), + ("USERPROFILE", None), + ]); + let key = crate::configuration::BootstrapChallengeKey::load().unwrap(); + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let url = format!("http://{}", listener.local_addr().unwrap()); + let state = state_dir().unwrap(); + create_private_dir(&state).unwrap(); + let path = owner_path(&state, &url); + let owner = OwnerRecord::new(42, &url, "shutdown-token", Some("fingerprint")); + write_owner_record(&path, &owner).unwrap(); + + let server = std::thread::spawn(move || { + let mut health = accept_bounded(&listener); + let request = read_headers(&mut health); + let nonce = header(&request, "x-nemo-relay-bootstrap-nonce"); + let proof = key.proof("fingerprint", &nonce); + let body = format!( + "{{\"status\":\"ok\",\"service\":\"nemo-relay\",\"version\":\"{}\",\"bootstrap_protocol\":{},\"instance_id\":\"test-instance\"}}", + env!("CARGO_PKG_VERSION"), + BOOTSTRAP_PROTOCOL_VERSION + ); + health + .write_all( + format!( + "HTTP/1.1 200 OK\r\nX-NeMo-Relay-Bootstrap-Proof: {proof}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ) + .as_bytes(), + ) + .unwrap(); + + let mut shutdown = accept_bounded(&listener); + let challenge = read_headers(&mut shutdown); + let nonce = header(&challenge, "x-nemo-relay-bootstrap-nonce"); + let proof = key.proof("fingerprint", &nonce); + shutdown + .write_all( + format!( + "HTTP/1.1 200 OK\r\nX-NeMo-Relay-Bootstrap-Proof: {proof}\r\nContent-Length: {}\r\nConnection: keep-alive\r\n\r\n{body}", + body.len() + ) + .as_bytes(), + ) + .unwrap(); + let request = read_headers(&mut shutdown); + assert!(request.starts_with("POST /bootstrap/shutdown HTTP/1.1")); + assert_eq!( + header(&request, "x-nemo-relay-bootstrap-token"), + "shutdown-token" + ); + // Close the listener before acknowledging shutdown so the verifier's + // immediate health probe cannot race this fixture's teardown. + drop(listener); + shutdown + .write_all(b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\nConnection: close\r\n\r\n") + .unwrap(); + }); + + stop_owned_and_reset(&url).unwrap(); + server.join().unwrap(); + assert!(!path.exists()); +} diff --git a/crates/cli/tests/coverage/shared/bootstrap_tests.rs b/crates/cli/tests/coverage/shared/bootstrap_tests.rs new file mode 100644 index 000000000..a668b386c --- /dev/null +++ b/crates/cli/tests/coverage/shared/bootstrap_tests.rs @@ -0,0 +1,235 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use super::*; +use crate::test_support::{EnvScope, accept_bounded, header, read_headers}; +use std::ffi::OsStr; +use std::io::Write; +use std::net::TcpListener; +use std::process::Command; + +#[test] +fn failed_reaper_spawn_terminates_and_reaps_the_retained_child() { + let child = Command::new(std::env::current_exe().unwrap()) + .arg("--list") + .stdout(Stdio::null()) + .spawn() + .unwrap(); + let terminated = std::sync::atomic::AtomicBool::new(false); + + let error = hand_off_to_reaper_with( + child, + |_| Err(std::io::Error::other("thread limit")), + |child| { + terminated.store(true, std::sync::atomic::Ordering::SeqCst); + let _ = child.kill(); + child.wait().unwrap(); + }, + ) + .unwrap_err(); + + assert!(terminated.load(std::sync::atomic::Ordering::SeqCst)); + assert!(error.contains("failed to start gateway reaper thread")); +} + +#[test] +fn persistent_gateway_requires_a_loopback_endpoint() { + let non_loopback = GatewaySpec::new("0.0.0.0:47632".parse().unwrap()) + .acquire() + .unwrap_err(); + assert!(non_loopback.contains("loopback"), "{non_loopback}"); +} + +#[test] +fn compatible_gateway_is_reused_without_starting_another_process() { + let temp = tempfile::tempdir().unwrap(); + let config = temp.path().join("config"); + let _environment = EnvScope::set(&[ + ("XDG_CONFIG_HOME", Some(config.as_os_str())), + ("HOME", Some(temp.path().as_os_str())), + ("USERPROFILE", None), + ]); + let key = crate::configuration::BootstrapChallengeKey::load().unwrap(); + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let server = std::thread::spawn(move || { + let mut stream = accept_bounded(&listener); + let request = read_headers(&mut stream); + let nonce = header(&request, "x-nemo-relay-bootstrap-nonce"); + let proof = key.proof("fingerprint", &nonce); + let body = format!( + "{{\"status\":\"ok\",\"service\":\"nemo-relay\",\"version\":\"{}\",\"bootstrap_protocol\":{},\"instance_id\":\"existing-instance\"}}", + "compatible-other-version", BOOTSTRAP_PROTOCOL_VERSION + ); + stream + .write_all( + format!( + "HTTP/1.1 200 OK\r\nX-NeMo-Relay-Bootstrap-Proof: {proof}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ) + .as_bytes(), + ) + .unwrap(); + }); + + let endpoint = GatewaySpec::new(address) + .with_fingerprint("fingerprint") + .acquire() + .unwrap(); + + server.join().unwrap(); + assert_eq!(endpoint.address, address); + assert_eq!(endpoint.instance_id, "existing-instance"); +} + +#[test] +fn foreign_and_incompatible_listeners_are_never_adopted() { + for (status, body, expected) in [ + ("200 OK", "{}", "not a compatible"), + ( + "409 Conflict", + "{\"status\":\"incompatible\",\"service\":\"nemo-relay\",\"version\":\"other\",\"bootstrap_protocol\":2,\"instance_id\":\"other\"}", + "different version", + ), + ] { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let body = body.to_string(); + listener.set_nonblocking(true).unwrap(); + let (stop_tx, stop_rx) = std::sync::mpsc::channel(); + let server = std::thread::spawn(move || { + loop { + if stop_rx.try_recv().is_ok() { + break; + } + match listener.accept() { + Ok((mut stream, _)) => { + stream + .set_read_timeout(Some(Duration::from_secs(5))) + .unwrap(); + let _ = read_headers(&mut stream); + stream + .write_all( + format!( + "HTTP/1.1 {status}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ) + .as_bytes(), + ) + .unwrap(); + } + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + std::thread::sleep(Duration::from_millis(10)); + } + Err(error) => panic!("failed to accept test connection: {error}"), + } + } + }); + + let error = GatewaySpec::new(address).acquire().unwrap_err(); + stop_tx.send(()).unwrap(); + server.join().unwrap(); + assert!(error.contains(expected), "{error}"); + } +} + +#[test] +fn readiness_file_requires_the_existing_server_identity() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("ready.json"); + assert!(read_ready_file(&path).unwrap().is_none()); + + std::fs::write( + &path, + format!( + "{{\"service\":\"nemo-relay\",\"version\":\"{}\",\"bootstrap_protocol\":{},\"address\":\"127.0.0.1:47632\",\"instance_id\":\"ready\"}}", + env!("CARGO_PKG_VERSION"), + BOOTSTRAP_PROTOCOL_VERSION + ), + ) + .unwrap(); + let endpoint = read_ready_file(&path).unwrap().unwrap(); + assert_eq!(endpoint.url, DEFAULT_URL); + assert_eq!(endpoint.instance_id, "ready"); + + std::fs::write(&path, "{}").unwrap(); + let error = read_ready_file(&path).unwrap_err(); + assert!(error.contains("failed to parse"), "{error}"); +} + +#[test] +fn persistent_gateway_resolution_keeps_server_configuration_in_one_spec() { + let temp = tempfile::tempdir().unwrap(); + let _environment = EnvScope::set(&[ + ("XDG_CONFIG_HOME", Some(temp.path().as_os_str())), + ("HOME", Some(temp.path().as_os_str())), + ("USERPROFILE", None), + ]); + let bind = DEFAULT_BIND.parse().unwrap(); + let resolved = resolve_plugin_gateway(&GatewayOverrides::default(), bind).unwrap(); + + assert_eq!(resolved.gateway.bind(), bind); + assert_eq!( + resolved.max_hook_payload_bytes, + crate::configuration::DEFAULT_MAX_HOOK_PAYLOAD_BYTES + ); + assert!(resolved.gateway.bootstrap_fingerprint.is_some()); + assert!(resolved.gateway.user_config_scope); + assert!( + resolved + .gateway + .launch_args + .iter() + .any(|arg| arg == "--max-hook-payload-bytes") + ); +} + +#[test] +fn idle_timeout_drives_heartbeat_and_rejects_invalid_values() { + let _environment = EnvScope::set(&[( + crate::configuration::PLUGIN_IDLE_TIMEOUT_ENV, + Some(OsStr::new("9")), + )]); + assert_eq!(plugin_idle_timeout().unwrap(), Duration::from_secs(9)); + assert_eq!(plugin_heartbeat_interval().unwrap(), Duration::from_secs(3)); + drop(_environment); + + let _environment = EnvScope::set(&[( + crate::configuration::PLUGIN_IDLE_TIMEOUT_ENV, + Some(OsStr::new("0")), + )]); + assert!( + plugin_idle_timeout() + .unwrap_err() + .contains("greater than 0") + ); +} + +#[test] +fn binary_override_is_explicit_and_validated() { + let temp = tempfile::tempdir().unwrap(); + let binary = temp.path().join("nemo-relay"); + std::fs::write(&binary, "").unwrap(); + let _environment = EnvScope::set(&[("NEMO_RELAY_PLUGIN_BINARY", Some(binary.as_os_str()))]); + assert_eq!(relay_binary().unwrap(), binary); + drop(_environment); + + let missing = temp.path().join("missing"); + let _environment = EnvScope::set(&[("NEMO_RELAY_PLUGIN_BINARY", Some(missing.as_os_str()))]); + assert!(relay_binary().unwrap_err().contains("does not exist")); +} + +#[test] +fn windows_detachment_requests_only_supported_breakaway_flags() { + let base = WINDOWS_CREATE_NEW_PROCESS_GROUP | WINDOWS_CREATE_NO_WINDOW; + assert_eq!(windows_detached_creation_flags(false, None), (base, false)); + assert_eq!( + windows_detached_creation_flags(true, Some(WINDOWS_JOB_OBJECT_LIMIT_BREAKAWAY_OK)), + (base | WINDOWS_CREATE_BREAKAWAY_FROM_JOB, false) + ); + assert_eq!( + windows_detached_creation_flags(true, Some(WINDOWS_JOB_OBJECT_LIMIT_SILENT_BREAKAWAY_OK)), + (base, false) + ); + assert_eq!(windows_detached_creation_flags(true, Some(0)), (base, true)); +} diff --git a/crates/cli/tests/coverage/completions_install_tests.rs b/crates/cli/tests/coverage/shared/completions_install_tests.rs similarity index 73% rename from crates/cli/tests/coverage/completions_install_tests.rs rename to crates/cli/tests/coverage/shared/completions_install_tests.rs index 2976887e1..9bf2923b2 100644 --- a/crates/cli/tests/coverage/completions_install_tests.rs +++ b/crates/cli/tests/coverage/shared/completions_install_tests.rs @@ -5,6 +5,7 @@ use super::*; use std::ffi::OsString; use std::path::PathBuf; +use crate::test_support::EnvScope; use clap_complete::Shell; #[test] @@ -103,45 +104,3 @@ fn install_writes_detected_shell_completion() { let script = std::fs::read_to_string(path).unwrap(); assert!(script.contains("nemo-relay")); } - -struct EnvScope { - _guard: std::sync::MutexGuard<'static, ()>, - values: Vec<(&'static str, Option)>, -} - -impl EnvScope { - fn set(values: &[(&'static str, Option<&std::ffi::OsStr>)]) -> Self { - let guard = crate::test_support::ENV_TEST_LOCK - .lock() - .unwrap_or_else(|error| error.into_inner()); - let previous = values - .iter() - .map(|(key, _)| (*key, std::env::var_os(key))) - .collect::>(); - for (key, value) in values { - unsafe { - match value { - Some(value) => std::env::set_var(key, value), - None => std::env::remove_var(key), - } - } - } - Self { - _guard: guard, - values: previous, - } - } -} - -impl Drop for EnvScope { - fn drop(&mut self) { - for (key, value) in self.values.drain(..) { - unsafe { - match value { - Some(value) => std::env::set_var(key, value), - None => std::env::remove_var(key), - } - } - } - } -} diff --git a/crates/cli/tests/coverage/config_tests.rs b/crates/cli/tests/coverage/shared/config_tests.rs similarity index 61% rename from crates/cli/tests/coverage/config_tests.rs rename to crates/cli/tests/coverage/shared/config_tests.rs index d1a3fc3d3..fbcd51429 100644 --- a/crates/cli/tests/coverage/config_tests.rs +++ b/crates/cli/tests/coverage/shared/config_tests.rs @@ -2,6 +2,11 @@ // SPDX-License-Identifier: Apache-2.0 use super::*; +use crate::agents::CodingAgent; +use crate::filesystem::bounded::MAX_BOUNDED_FILE_BYTES as MAX_BOOTSTRAP_IDENTITY_FILE_BYTES; +#[cfg(unix)] +use crate::filesystem::bounded::read_bounded_regular_file; +use crate::hooks::GatewayMode; use axum::http::HeaderValue; use base64::Engine; use nemo_relay::plugin::dynamic::{ @@ -22,26 +27,59 @@ use crate::plugins::policy::{ }; struct PluginConfigDiscoveryScope { + _cwd_guard: crate::test_support::CwdTestScope, _guard: MutexGuard<'static, ()>, previous_cwd: PathBuf, previous_xdg_config_home: Option, + previous_config_scope: Option, + previous_openai_api_key: Option, + previous_bootstrap_fingerprint: Option, + previous_plugin_idle_timeout: Option, } impl PluginConfigDiscoveryScope { fn enter(cwd: &std::path::Path, xdg_config_home: &std::path::Path) -> Self { + let cwd_guard = crate::test_support::CwdTestScope::locked(); let guard = crate::test_support::ENV_TEST_LOCK .lock() .unwrap_or_else(|error| error.into_inner()); let previous_cwd = std::env::current_dir().unwrap(); let previous_xdg_config_home = std::env::var_os("XDG_CONFIG_HOME"); + let previous_config_scope = std::env::var_os("NEMO_RELAY_CONFIG_SCOPE"); + let previous_openai_api_key = std::env::var_os("OPENAI_API_KEY"); + let previous_bootstrap_fingerprint = std::env::var_os(BOOTSTRAP_FINGERPRINT_ENV); + let previous_plugin_idle_timeout = std::env::var_os(PLUGIN_IDLE_TIMEOUT_ENV); unsafe { std::env::set_var("XDG_CONFIG_HOME", xdg_config_home); + std::env::remove_var("NEMO_RELAY_CONFIG_SCOPE"); + std::env::remove_var("OPENAI_API_KEY"); + std::env::remove_var(BOOTSTRAP_FINGERPRINT_ENV); + std::env::remove_var(PLUGIN_IDLE_TIMEOUT_ENV); } std::env::set_current_dir(cwd).unwrap(); Self { + _cwd_guard: cwd_guard, _guard: guard, previous_cwd, previous_xdg_config_home, + previous_config_scope, + previous_openai_api_key, + previous_bootstrap_fingerprint, + previous_plugin_idle_timeout, + } + } + + fn enable_user_scope(&self) { + // SAFETY: This scope holds the process-wide environment mutex. + unsafe { + std::env::set_var("NEMO_RELAY_CONFIG_SCOPE", "user"); + } + } + + fn set_bootstrap_fingerprint(&self, fingerprint: &str) { + // SAFETY: This scope holds the process-wide environment mutex. + unsafe { + std::env::set_var(BOOTSTRAP_FINGERPRINT_ENV, fingerprint); } } } @@ -54,6 +92,22 @@ impl Drop for PluginConfigDiscoveryScope { Some(value) => std::env::set_var("XDG_CONFIG_HOME", value), None => std::env::remove_var("XDG_CONFIG_HOME"), } + match self.previous_config_scope.take() { + Some(value) => std::env::set_var("NEMO_RELAY_CONFIG_SCOPE", value), + None => std::env::remove_var("NEMO_RELAY_CONFIG_SCOPE"), + } + match self.previous_openai_api_key.take() { + Some(value) => std::env::set_var("OPENAI_API_KEY", value), + None => std::env::remove_var("OPENAI_API_KEY"), + } + match self.previous_bootstrap_fingerprint.take() { + Some(value) => std::env::set_var(BOOTSTRAP_FINGERPRINT_ENV, value), + None => std::env::remove_var(BOOTSTRAP_FINGERPRINT_ENV), + } + match self.previous_plugin_idle_timeout.take() { + Some(value) => std::env::set_var(PLUGIN_IDLE_TIMEOUT_ENV, value), + None => std::env::remove_var(PLUGIN_IDLE_TIMEOUT_ENV), + } } } } @@ -66,8 +120,8 @@ fn config() -> GatewayConfig { anthropic_base_url: "http://anthropic".into(), metadata: None, plugin_config: None, - max_hook_payload_bytes: crate::config::DEFAULT_MAX_HOOK_PAYLOAD_BYTES, - max_passthrough_body_bytes: crate::config::DEFAULT_MAX_PASSTHROUGH_BODY_BYTES, + max_hook_payload_bytes: crate::configuration::DEFAULT_MAX_HOOK_PAYLOAD_BYTES, + max_passthrough_body_bytes: crate::configuration::DEFAULT_MAX_PASSTHROUGH_BODY_BYTES, } } @@ -113,6 +167,27 @@ fn isolated_config_path(temp: &tempfile::TempDir) -> std::path::PathBuf { temp.path().join("config.toml") } +fn write_attested_python_environment(path: &std::path::Path, manifest_path: &std::path::Path) { + let interpreter = if cfg!(windows) { + path.join("Scripts/python.exe") + } else { + path.join("bin/python") + }; + std::fs::create_dir_all(interpreter.parent().unwrap()).unwrap(); + std::fs::write(interpreter, b"fixture interpreter").unwrap(); + let installed = path.join("site-packages/fixture.py"); + std::fs::create_dir_all(installed.parent().unwrap()).unwrap(); + std::fs::write(installed, b"fixture = True\n").unwrap(); + let (manifest, _) = DynamicPluginManifest::load_from_path(manifest_path).unwrap(); + let source_artifact_sha256 = manifest + .integrity + .as_ref() + .and_then(|integrity| integrity.sha256.as_deref()) + .unwrap(); + crate::plugins::lifecycle::attest_test_python_environment(path, source_artifact_sha256) + .unwrap(); +} + fn write_dynamic_manifest(dir: &std::path::Path, plugin_id: &str) -> std::path::PathBuf { write_dynamic_manifest_with_options(dir, plugin_id, &["plugin_worker"], None) } @@ -162,6 +237,7 @@ enabled = false items = [{capabilities}] [source] +manifest_root = "." artifact = "plugin.py" [integrity] @@ -170,7 +246,7 @@ sha256 = "{digest}" [load] runtime = "python" -entrypoint = "{plugin_id}.plugin:register" +entrypoint = "plugin:register" "#, capabilities = capabilities, signature_line = signature_line, @@ -338,7 +414,7 @@ command = "hermes --yolo chat" "#, ) .unwrap(); - let command = RunCommand { + let command = RunOverrides { agent: None, config: Some(path), openai_base_url: None, @@ -373,7 +449,7 @@ command = "hermes --yolo chat" fn explicit_config_must_exist() { let temp = tempfile::tempdir().unwrap(); let path = temp.path().join("missing-config.toml"); - let command = RunCommand { + let command = RunOverrides { agent: None, config: Some(path.clone()), openai_base_url: None, @@ -406,11 +482,15 @@ fn absent_optional_plugin_config_is_ignored() { fn unreadable_config_errors_include_the_source_path() { use std::os::unix::fs::PermissionsExt; + if unsafe { libc::geteuid() } == 0 { + return; + } + let temp = tempfile::tempdir().unwrap(); let config_path = temp.path().join("config.toml"); std::fs::write(&config_path, "").unwrap(); std::fs::set_permissions(&config_path, std::fs::Permissions::from_mode(0o000)).unwrap(); - let command = RunCommand { + let command = RunOverrides { agent: None, config: Some(config_path.clone()), openai_base_url: None, @@ -467,7 +547,7 @@ fn legacy_observability_config_sections_fail_clearly() { ] { let path = temp.path().join(name); std::fs::write(&path, contents).unwrap(); - let command = RunCommand { + let command = RunOverrides { agent: None, config: Some(path), openai_base_url: None, @@ -520,7 +600,7 @@ mode = "overwrite" "#, ) .unwrap(); - let command = RunCommand { + let command = RunOverrides { agent: Some(CodingAgent::Codex), config: Some(config_path), openai_base_url: None, @@ -597,6 +677,35 @@ fn plugins_toml_path_resolution_tracks_config_scope() { ); } +#[test] +fn persistent_user_scope_excludes_project_gateway_and_plugin_layers() { + let temp = tempfile::tempdir().unwrap(); + let project = temp.path().join("workspace"); + let nested = project.join("nested"); + let xdg = temp.path().join("xdg"); + std::fs::create_dir_all(project.join(".nemo-relay")).unwrap(); + std::fs::create_dir_all(&nested).unwrap(); + std::fs::write(project.join(".nemo-relay/config.toml"), "").unwrap(); + std::fs::write(project.join(".nemo-relay/plugins.toml"), "version = 1\n").unwrap(); + let scope = PluginConfigDiscoveryScope::enter(&nested, &xdg); + scope.enable_user_scope(); + + assert_eq!( + config_paths(None), + vec![ + PathBuf::from("/etc/nemo-relay/config.toml"), + xdg.join("nemo-relay/config.toml"), + ] + ); + assert_eq!( + plugin_config_paths(None, None), + vec![ + PathBuf::from("/etc/nemo-relay/plugins.toml"), + xdg.join("nemo-relay/plugins.toml"), + ] + ); +} + #[test] fn discovered_plugins_toml_upserts_components_by_kind() { let temp = tempfile::tempdir().unwrap(); @@ -1317,9 +1426,9 @@ config = { version = 1, components = [] } "#, ) .unwrap(); - let args = ServerArgs { + let args = GatewayOverrides { config: Some(config_path), - ..ServerArgs::default() + ..GatewayOverrides::default() }; let error = resolve_server_config(&args).unwrap_err().to_string(); @@ -1338,7 +1447,7 @@ fn plugin_config_path_overrides_sibling_plugin_file() { std::fs::write(&config_path, "").unwrap(); std::fs::write(&sibling_path, "version = 1\n").unwrap(); std::fs::write(&override_path, "version = 2\n").unwrap(); - let command = RunCommand { + let command = RunOverrides { agent: Some(CodingAgent::Codex), config: Some(config_path), openai_base_url: None, @@ -1370,7 +1479,7 @@ openai_base_url = "http://file-openai" "#, ) .unwrap(); - let command = RunCommand { + let command = RunOverrides { agent: Some(CodingAgent::Codex), config: Some(path), openai_base_url: Some("http://cli-openai".into()), @@ -1400,12 +1509,12 @@ openai_base_url = "http://file-openai" "#, ) .unwrap(); - let server = ServerArgs { + let server = GatewayOverrides { config: Some(path), openai_base_url: Some("http://top-level-openai".into()), - ..ServerArgs::default() + ..GatewayOverrides::default() }; - let command = RunCommand { + let command = RunOverrides { agent: Some(CodingAgent::Codex), config: None, openai_base_url: None, @@ -1425,14 +1534,18 @@ openai_base_url = "http://file-openai" #[test] fn server_resolution_applies_all_server_overrides() { let temp = tempfile::tempdir().unwrap(); + let xdg = temp.path().join("xdg"); + std::fs::create_dir_all(&xdg).unwrap(); + let _scope = PluginConfigDiscoveryScope::enter(temp.path(), &xdg); let config_path = isolated_config_path(&temp); std::fs::write(&config_path, "").unwrap(); - let args = ServerArgs { + let args = GatewayOverrides { config: Some(config_path), bind: Some("127.0.0.1:0".parse().unwrap()), openai_base_url: Some("http://cli-openai".into()), anthropic_base_url: Some("http://cli-anthropic".into()), plugin_config_path: None, + ready_file: None, max_hook_payload_bytes: Some(222), max_passthrough_body_bytes: Some(333), }; @@ -1445,9 +1558,752 @@ fn server_resolution_applies_all_server_overrides() { assert_eq!(resolved.gateway.max_hook_payload_bytes, 222); assert_eq!(resolved.gateway.max_passthrough_body_bytes, 333); assert_eq!(resolved.gateway.plugin_config, None); + assert_eq!(resolved.bootstrap_fingerprint, None); + assert!( + !xdg.join("nemo-relay/bootstrap/fingerprint-hmac.key") + .exists() + ); assert!(args.requested_daemon_mode()); } +#[test] +fn ordinary_server_ignores_managed_bootstrap_fingerprint_environment() { + let temp = tempfile::tempdir().unwrap(); + let xdg = temp.path().join("xdg"); + std::fs::create_dir_all(&xdg).unwrap(); + let scope = PluginConfigDiscoveryScope::enter(temp.path(), &xdg); + scope.set_bootstrap_fingerprint("opaque-parent-fingerprint"); + let config_path = isolated_config_path(&temp); + std::fs::write(&config_path, "").unwrap(); + + let args = GatewayOverrides { + config: Some(config_path), + bind: Some("127.0.0.1:0".parse().unwrap()), + ..GatewayOverrides::default() + }; + let resolved = resolve_server_config(&args).unwrap(); + + assert_eq!(resolved.bootstrap_fingerprint, None); + assert!( + managed_bootstrap_identity(&args, &resolved, &[]) + .unwrap() + .is_none() + ); + assert!( + !xdg.join("nemo-relay/bootstrap/fingerprint-hmac.key") + .exists() + ); + + scope.set_bootstrap_fingerprint(""); + let managed_args = GatewayOverrides { + ready_file: Some(temp.path().join("managed.ready.json")), + ..args + }; + let error = managed_bootstrap_identity(&managed_args, &resolved, &[]).unwrap_err(); + assert!(error.to_string().contains("must be set and non-empty")); +} + +#[test] +fn managed_bootstrap_environment_is_not_forwarded_from_codex() { + let names = crate::mcp_environment::forwarded_names( + [ + "NEMO_RELAY_BOOTSTRAP_AGENT".to_string(), + "NEMO_RELAY_BOOTSTRAP_FINGERPRINT".to_string(), + "NEMO_RELAY_BOOTSTRAP_STATE_DIR".to_string(), + "NEMO_RELAY_BOOTSTRAP_SHUTDOWN_TOKEN".to_string(), + ], + None, + ); + + assert!(!names.iter().any(|name| name.contains("BOOTSTRAP"))); +} + +#[test] +fn mcp_environment_policy_handles_unresolved_values_and_historical_names_per_platform() { + assert!( + crate::mcp_environment::unresolved_self_placeholder_for_platform( + "AWS_ROLE_ARN", + "${AWS_ROLE_ARN}", + false, + ) + ); + assert!( + !crate::mcp_environment::unresolved_self_placeholder_for_platform( + "AWS_ROLE_ARN", + "${aws_role_arn}", + false, + ) + ); + assert!( + crate::mcp_environment::unresolved_self_placeholder_for_platform( + "AWS_ROLE_ARN", + "${aws_role_arn}", + true, + ) + ); + assert!( + !crate::mcp_environment::unresolved_self_placeholder_for_platform( + "AWS_ROLE_ARN", + "real-value", + true, + ) + ); + + for allowed in ["AWS_PROFILE", "NEMO_RELAY_CUSTOM", "OTEL_CUSTOM"] { + assert!( + crate::mcp_environment::previously_forwardable_name_for_platform(allowed, false), + "rejected {allowed}" + ); + } + assert!(crate::mcp_environment::previously_forwardable_name_for_platform("Aws_Custom", true,)); + for rejected in [ + "UNRELATED_SECRET", + "NEMO_RELAY_WORKER_TOKEN", + "NEMO_RELAY_TEST_CAPTURE", + ] { + assert!( + !crate::mcp_environment::previously_forwardable_name_for_platform(rejected, true), + "accepted {rejected}" + ); + } +} + +#[test] +fn transparent_gateway_fingerprint_is_stable_and_endpoint_specific() { + let first = transparent_gateway_fingerprint("http://127.0.0.1:41001"); + let repeated = transparent_gateway_fingerprint("http://127.0.0.1:41001"); + let second = transparent_gateway_fingerprint("http://127.0.0.1:41002"); + + assert_eq!(first, repeated); + assert_ne!(first, second); + assert!(first.starts_with("transparent-sha256:")); + assert_eq!(first.len(), "transparent-sha256:".len() + 64); +} + +#[test] +fn bootstrap_health_proofs_and_client_tokens_reject_every_malformed_shape() { + let key = BootstrapChallengeKey::from_bytes(&[7_u8; BOOTSTRAP_HMAC_KEY_BYTES]); + let other = BootstrapChallengeKey::from_bytes(&[8_u8; BOOTSTRAP_HMAC_KEY_BYTES]); + let fingerprint = "hmac-sha256:fixture"; + let nonce = "nonce"; + let proof = key.proof(fingerprint, nonce); + + assert!(key.verify(fingerprint, nonce, &proof)); + assert!(!key.verify("other", nonce, &proof)); + assert!(!key.verify(fingerprint, "other", &proof)); + for malformed in [ + "missing-prefix", + "hmac-sha256:short", + "hmac-sha256:zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz", + ] { + assert!(!key.verify(fingerprint, nonce, malformed)); + } + + let token = key.client_token(); + assert!(key.verify_client_token(&token)); + assert!(!other.verify_client_token(&token)); + for malformed in [ + "missing-prefix", + "hmac-sha256:short", + "hmac-sha256:zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz", + ] { + assert!(!key.verify_client_token(malformed)); + } + + assert!( + !verify_python_environment_attestation("source", "environment", "missing-prefix").unwrap() + ); + assert!( + !verify_python_environment_attestation("source", "environment", "hmac-sha256:short") + .unwrap() + ); +} + +#[test] +fn bootstrap_hmac_state_reports_invalid_path_and_existing_key_shapes() { + let temp = tempfile::tempdir().unwrap(); + let xdg = temp.path().join("xdg"); + std::fs::create_dir_all(&xdg).unwrap(); + let _scope = PluginConfigDiscoveryScope::enter(temp.path(), &xdg); + + let root_error = load_or_create_bootstrap_hmac_key_at(Path::new("/")).unwrap_err(); + assert!( + root_error.to_string().contains("no parent directory"), + "{root_error}" + ); + + let parent_file = temp.path().join("not-a-directory"); + std::fs::write(&parent_file, b"file").unwrap(); + let parent_error = load_or_create_bootstrap_hmac_key_at(&parent_file.join("key")).unwrap_err(); + assert!( + parent_error.to_string().contains("failed to create"), + "{parent_error}" + ); + + let directory_key = temp.path().join("directory-key"); + std::fs::create_dir(&directory_key).unwrap(); + let open_error = load_or_create_bootstrap_hmac_key_at(&directory_key).unwrap_err(); + assert!( + open_error.to_string().contains("failed to open"), + "{open_error}" + ); + + let configured_key = xdg + .join("nemo-relay") + .join("bootstrap") + .join("fingerprint-hmac.key"); + std::fs::create_dir_all(configured_key.parent().unwrap()).unwrap(); + std::fs::write(&configured_key, b"short").unwrap(); + let existing_error = BootstrapChallengeKey::load_existing() + .err() + .expect("corrupt existing bootstrap key was accepted"); + assert!( + existing_error.to_string().contains("invalid length 5"), + "{existing_error}" + ); +} + +#[cfg(unix)] +#[test] +fn bounded_identity_reader_reports_missing_unreadable_and_invalid_utf8_inputs() { + use std::os::unix::fs::PermissionsExt; + + let temp = tempfile::tempdir().unwrap(); + let missing = temp.path().join("missing"); + let missing_error = read_bounded_regular_file(&missing, "fixture").unwrap_err(); + assert!( + missing_error.contains("failed to inspect"), + "{missing_error}" + ); + + let unreadable = temp.path().join("unreadable"); + if unsafe { libc::geteuid() } != 0 { + std::fs::write(&unreadable, b"contents").unwrap(); + std::fs::set_permissions(&unreadable, std::fs::Permissions::from_mode(0o000)).unwrap(); + let unreadable_result = read_bounded_regular_file(&unreadable, "fixture"); + std::fs::set_permissions(&unreadable, std::fs::Permissions::from_mode(0o600)).unwrap(); + let unreadable_error = unreadable_result.unwrap_err(); + assert!( + unreadable_error.contains("failed to read"), + "{unreadable_error}" + ); + } + + let manifest = temp.path().join("invalid-utf8.toml"); + std::fs::write(&manifest, [0xff_u8]).unwrap(); + let utf8_error = load_bounded_dynamic_plugin_manifest_bytes(&manifest).unwrap_err(); + assert!( + utf8_error.to_string().contains("is not UTF-8"), + "{utf8_error}" + ); + + assert_eq!( + resolve_dynamic_plugin_relative_path(Path::new("/manifest.toml"), "/artifact"), + PathBuf::from("/artifact") + ); +} + +#[test] +fn persistent_server_resolution_excludes_project_config_and_fingerprints_credentials() { + let temp = tempfile::tempdir().unwrap(); + let project = temp.path().join("project"); + let xdg = temp.path().join("xdg"); + std::fs::create_dir_all(project.join(".nemo-relay")).unwrap(); + std::fs::create_dir_all(&xdg).unwrap(); + std::fs::write( + project.join(".nemo-relay/config.toml"), + "[upstream]\nopenai_base_url = \"http://project-only\"\n", + ) + .unwrap(); + let _scope = PluginConfigDiscoveryScope::enter(&project, &xdg); + let args = GatewayOverrides { + bind: Some("127.0.0.1:47632".parse().unwrap()), + ..GatewayOverrides::default() + }; + + unsafe { std::env::set_var("OPENAI_API_KEY", "credential-one") }; + let first = resolve_persistent_server_config(&args).unwrap(); + assert_ne!(first.gateway.openai_base_url, "http://project-only"); + assert!( + first + .bootstrap_fingerprint + .as_deref() + .unwrap() + .starts_with("hmac-sha256:") + ); + let key_path = xdg + .join("nemo-relay") + .join("bootstrap") + .join("fingerprint-hmac.key"); + assert_eq!(std::fs::metadata(&key_path).unwrap().len(), 32); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + std::fs::metadata(&key_path).unwrap().permissions().mode() & 0o777, + 0o600 + ); + } + + unsafe { std::env::set_var("OPENAI_API_KEY", "credential-two") }; + let second = resolve_persistent_server_config(&args).unwrap(); + assert_ne!(first.bootstrap_fingerprint, second.bootstrap_fingerprint); +} + +#[test] +fn managed_bootstrap_canonicalizes_unset_and_zero_padded_default_idle_timeout() { + let temp = tempfile::tempdir().unwrap(); + let xdg = temp.path().join("xdg"); + std::fs::create_dir_all(&xdg).unwrap(); + let scope = PluginConfigDiscoveryScope::enter(temp.path(), &xdg); + let parent = resolve_persistent_server_config(&GatewayOverrides::default()).unwrap(); + let expected = parent.bootstrap_fingerprint.unwrap(); + scope.set_bootstrap_fingerprint(&expected); + unsafe { + std::env::set_var(PLUGIN_IDLE_TIMEOUT_ENV, "0300"); + } + let child_args = GatewayOverrides { + ready_file: Some(temp.path().join("managed.ready.json")), + ..GatewayOverrides::default() + }; + let child = resolve_server_config(&child_args).unwrap(); + let active = active_dynamic_plugin_components(None, &child).unwrap(); + let identity = managed_bootstrap_identity(&child_args, &child, &active) + .unwrap() + .unwrap(); + + assert_eq!(identity.fingerprint(), expected); + identity.verify_current().unwrap(); +} + +#[test] +fn plugin_launch_carries_effective_hook_limit_below_and_above_default() { + let temp = tempfile::tempdir().unwrap(); + let xdg = temp.path().join("xdg"); + let user_config = xdg.join("nemo-relay/config.toml"); + std::fs::create_dir_all(user_config.parent().unwrap()).unwrap(); + let _scope = PluginConfigDiscoveryScope::enter(temp.path(), &xdg); + let bind = "127.0.0.1:47632".parse().unwrap(); + + for limit in [1024, DEFAULT_MAX_HOOK_PAYLOAD_BYTES + 4096] { + std::fs::write( + &user_config, + format!("[gateway]\nmax_hook_payload_bytes = {limit}\n"), + ) + .unwrap(); + let launch = + crate::bootstrap::resolve_plugin_gateway(&GatewayOverrides::default(), bind).unwrap(); + assert_eq!(launch.max_hook_payload_bytes, limit); + } +} + +#[test] +fn bootstrap_hmac_key_creation_is_concurrency_safe_and_stable() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("state/fingerprint-hmac.key"); + let barrier = std::sync::Arc::new(std::sync::Barrier::new(8)); + let handles = (0..8) + .map(|_| { + let path = path.clone(); + let barrier = barrier.clone(); + std::thread::spawn(move || { + barrier.wait(); + load_or_create_bootstrap_hmac_key_at(&path).unwrap() + }) + }) + .collect::>(); + let keys = handles + .into_iter() + .map(|handle| handle.join().unwrap()) + .collect::>(); + + assert!(keys.windows(2).all(|pair| pair[0] == pair[1])); + assert_eq!(std::fs::metadata(path).unwrap().len(), 32); +} + +#[cfg(windows)] +#[test] +fn bootstrap_hmac_key_uses_and_repairs_a_private_windows_dacl() { + let temp = tempfile::tempdir().unwrap(); + set_test_windows_dacl(temp.path(), "D:P(A;;FA;;;WD)"); + let path = temp.path().join("state/fingerprint-hmac.key"); + + let original = load_or_create_bootstrap_hmac_key_at(&path).unwrap(); + + assert!(crate::filesystem::windows_path_is_private(path.parent().unwrap()).unwrap()); + assert!(crate::filesystem::windows_path_is_private(&path).unwrap()); + + set_test_windows_dacl(&path, "D:P(A;;FA;;;WD)"); + assert!(!crate::filesystem::windows_path_is_private(&path).unwrap()); + + let reloaded = load_or_create_bootstrap_hmac_key_at(&path).unwrap(); + + assert_eq!(reloaded, original); + assert!(crate::filesystem::windows_path_is_private(&path).unwrap()); +} + +#[test] +fn bootstrap_hmac_key_lock_wait_is_bounded_under_synchronized_contention() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("state/fingerprint-hmac.key"); + load_or_create_bootstrap_hmac_key_at(&path).unwrap(); + let owner = OpenOptions::new() + .read(true) + .write(true) + .open(&path) + .unwrap(); + fs2::FileExt::lock_exclusive(&owner).unwrap(); + let barrier = std::sync::Arc::new(std::sync::Barrier::new(2)); + let waiter = { + let barrier = barrier.clone(); + let path = path.clone(); + std::thread::spawn(move || { + barrier.wait(); + load_or_create_bootstrap_hmac_key_at_with_timeout(&path, Duration::from_millis(75)) + .unwrap_err() + }) + }; + barrier.wait(); + + let error = waiter.join().unwrap(); + + assert!(error.to_string().contains("timed out waiting")); + drop(owner); +} + +#[test] +fn persistent_fingerprint_tracks_active_dynamic_plugin_and_file_identity() { + let temp = tempfile::tempdir().unwrap(); + let xdg = temp.path().join("xdg"); + let plugin_dir = temp.path().join("plugin"); + std::fs::create_dir_all(&xdg).unwrap(); + std::fs::create_dir_all(&plugin_dir).unwrap(); + let _scope = PluginConfigDiscoveryScope::enter(temp.path(), &xdg); + let manifest_path = write_dynamic_manifest(&plugin_dir, "acme.bootstrap-identity"); + let environment_a = temp.path().join("managed/environment-a"); + let environment_b = temp.path().join("managed/environment-b"); + write_attested_python_environment(&environment_a, &manifest_path); + write_attested_python_environment(&environment_b, &manifest_path); + let resolved = ResolvedConfig { + gateway: config(), + ..ResolvedConfig::default() + }; + let active = ActiveDynamicPluginComponent { + plugin_id: "acme.bootstrap-identity".into(), + kind: DynamicPluginKind::Worker, + lifecycle_generation: 7, + manifest_ref: Some(manifest_path.to_string_lossy().into_owned()), + environment_ref: Some(environment_a.to_string_lossy().into_owned()), + config: Map::new(), + activation_snapshot: None, + }; + + let inactive = persistent_bootstrap_fingerprint(&resolved, &[]).unwrap(); + let enabled = + persistent_bootstrap_fingerprint(&resolved, std::slice::from_ref(&active)).unwrap(); + assert_ne!( + inactive, enabled, + "enable/disable or tombstone must conflict" + ); + + std::fs::write(plugin_dir.join("plugin.py"), b"changed artifact").unwrap(); + let artifact_changed = + persistent_bootstrap_fingerprint(&resolved, std::slice::from_ref(&active)).unwrap(); + assert_ne!(enabled, artifact_changed); + + let manifest = std::fs::read_to_string(&manifest_path).unwrap(); + std::fs::write( + &manifest_path, + manifest.replace( + "id = \"acme.bootstrap-identity\"", + "id = \"acme.bootstrap-identity\"\nname = \"changed manifest\"", + ), + ) + .unwrap(); + let manifest_changed = + persistent_bootstrap_fingerprint(&resolved, std::slice::from_ref(&active)).unwrap(); + assert_ne!(artifact_changed, manifest_changed); + + let mut rebuilt_environment = active.clone(); + rebuilt_environment.lifecycle_generation += 1; + let rebuilt_environment_fingerprint = + persistent_bootstrap_fingerprint(&resolved, &[rebuilt_environment]).unwrap(); + assert_ne!( + manifest_changed, rebuilt_environment_fingerprint, + "a same-path managed environment rebuild must conflict through lifecycle generation" + ); + + let mut environment_changed = active; + environment_changed.environment_ref = Some(environment_b.to_string_lossy().into_owned()); + let environment_changed = + persistent_bootstrap_fingerprint(&resolved, &[environment_changed]).unwrap(); + assert_ne!(manifest_changed, environment_changed); +} + +#[test] +fn persistent_hook_identity_authenticates_python_marker_without_rehashing_environment() { + let temp = tempfile::tempdir().unwrap(); + let project = temp.path().join("project"); + let xdg = temp.path().join("xdg"); + let user_config = xdg.join("nemo-relay"); + let plugin_dir = temp.path().join("python-plugin"); + std::fs::create_dir_all(&project).unwrap(); + std::fs::create_dir_all(&user_config).unwrap(); + std::fs::create_dir_all(&plugin_dir).unwrap(); + let _scope = PluginConfigDiscoveryScope::enter(&project, &xdg); + let plugin_id = "acme.read-only-hook-identity"; + let manifest_path = write_dynamic_manifest(&plugin_dir, plugin_id); + let plugins_toml = user_config.join("plugins.toml"); + std::fs::write( + &plugins_toml, + format!( + "version = 1\n\n[[plugins.dynamic]]\nmanifest = {:?}\n", + manifest_path.to_string_lossy() + ), + ) + .unwrap(); + let environment_name = Sha256::digest(plugin_id.as_bytes()) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::(); + let environment = user_config + .join(".dynamic-plugin-environments") + .join(environment_name); + write_attested_python_environment(&environment, &manifest_path); + let (manifest, manifest_ref) = DynamicPluginManifest::load_from_path(&manifest_path).unwrap(); + let mut record = manifest.into_record(Some(manifest_ref)).unwrap(); + record.spec.enabled = true; + record.source.environment_ref = Some(environment.to_string_lossy().into_owned()); + std::fs::write( + user_config.join(".dynamic-plugins.json"), + serde_json::to_vec_pretty(&json!({ + "schema_version": 1, + "records": [record], + })) + .unwrap(), + ) + .unwrap(); + + crate::plugins::lifecycle::reset_test_python_environment_digest_calls(); + let before = resolve_persistent_server_config(&GatewayOverrides::default()).unwrap(); + assert_eq!( + crate::plugins::lifecycle::test_python_environment_digest_calls(), + 0, + "persistent hook identity must trust only the authenticated environment marker" + ); + + let resolved = load_shared_config_scoped(None, None, true).unwrap(); + let active = active_dynamic_plugin_components(None, &resolved).unwrap(); + assert_eq!(active.len(), 1); + assert!(active[0].activation_snapshot.is_some()); + let snapshot_fingerprint = persistent_bootstrap_fingerprint(&resolved, &active).unwrap(); + assert!(snapshot_fingerprint.starts_with("hmac-sha256:")); + assert!( + crate::plugins::lifecycle::test_python_environment_digest_calls() > 0, + "activation must verify the complete environment before snapshotting it" + ); + crate::plugins::lifecycle::reset_test_python_environment_digest_calls(); + + std::fs::write( + environment.join("site-packages/fixture.py"), + b"fixture = 'mutated'\n", + ) + .unwrap(); + let after = resolve_persistent_server_config(&GatewayOverrides::default()).unwrap(); + assert_eq!( + crate::plugins::lifecycle::test_python_environment_digest_calls(), + 0, + "mutating environment content must not make hook preflight traverse it" + ); + assert_eq!(before.bootstrap_fingerprint, after.bootstrap_fingerprint); + + let error = active_dynamic_plugin_components(None, &resolved) + .unwrap_err() + .to_string(); + assert!(error.contains("changed after provisioning"), "{error}"); + assert!( + crate::plugins::lifecycle::test_python_environment_digest_calls() > 0, + "sidecar activation must still perform the full environment verification" + ); +} + +#[test] +fn managed_server_rejects_config_and_artifact_changes_after_parent_resolution() { + let temp = tempfile::tempdir().unwrap(); + let xdg = temp.path().join("xdg"); + let plugin_dir = temp.path().join("plugin"); + std::fs::create_dir_all(&xdg).unwrap(); + std::fs::create_dir_all(&plugin_dir).unwrap(); + let scope = PluginConfigDiscoveryScope::enter(temp.path(), &xdg); + let manifest_path = write_dynamic_manifest(&plugin_dir, "acme.bootstrap-race"); + let environment = temp.path().join("managed/environment"); + write_attested_python_environment(&environment, &manifest_path); + let resolved = ResolvedConfig { + gateway: config(), + ..ResolvedConfig::default() + }; + let active = ActiveDynamicPluginComponent { + plugin_id: "acme.bootstrap-race".into(), + kind: DynamicPluginKind::Worker, + lifecycle_generation: 3, + manifest_ref: Some(manifest_path.to_string_lossy().into_owned()), + environment_ref: Some(environment.to_string_lossy().into_owned()), + config: Map::new(), + activation_snapshot: None, + }; + let expected = + persistent_bootstrap_fingerprint(&resolved, std::slice::from_ref(&active)).unwrap(); + scope.set_bootstrap_fingerprint(&expected); + let args = GatewayOverrides { + ready_file: Some(temp.path().join("managed.ready.json")), + ..GatewayOverrides::default() + }; + + let identity = managed_bootstrap_identity(&args, &resolved, std::slice::from_ref(&active)) + .unwrap() + .unwrap(); + assert_eq!(identity.fingerprint(), expected); + + let mut changed_config = resolved.clone(); + changed_config.gateway.openai_base_url = "https://changed.invalid/v1".into(); + let config_error = + managed_bootstrap_identity(&args, &changed_config, std::slice::from_ref(&active)) + .unwrap_err(); + assert!(config_error.to_string().contains("identity changed")); + + std::fs::write(plugin_dir.join("plugin.py"), b"changed during bootstrap").unwrap(); + let artifact_error = identity.verify_current().unwrap_err(); + assert!(artifact_error.to_string().contains("identity changed")); +} + +#[test] +fn bootstrap_file_digest_streams_across_internal_buffer_boundaries() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("large-artifact.bin"); + let bytes = (0..(128 * 1024 + 17)) + .map(|index| (index % 251) as u8) + .collect::>(); + std::fs::write(&path, &bytes).unwrap(); + let expected = Sha256::digest(&bytes) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::(); + + assert_eq!( + bootstrap_file_digest(&path, "test artifact").unwrap(), + expected + ); +} + +#[test] +fn bootstrap_file_digest_rejects_non_regular_and_oversized_inputs() { + let temp = tempfile::tempdir().unwrap(); + let non_regular = bootstrap_file_digest(temp.path(), "test artifact").unwrap_err(); + assert!(non_regular.to_string().contains("must be a regular file")); + + let oversized = temp.path().join("oversized-artifact.bin"); + let file = std::fs::File::create(&oversized).unwrap(); + file.set_len(MAX_BOOTSTRAP_IDENTITY_FILE_BYTES + 1).unwrap(); + let oversized = bootstrap_file_digest(&oversized, "test artifact").unwrap_err(); + assert!(oversized.to_string().contains("exceeds")); +} + +#[test] +fn persistent_server_resolution_rejects_oversized_sparse_dynamic_plugin_manifest() { + let temp = tempfile::tempdir().unwrap(); + let xdg = temp.path().join("xdg"); + let user_config_dir = xdg.join("nemo-relay"); + let plugin_dir = user_config_dir.join("plugins/acme"); + std::fs::create_dir_all(&plugin_dir).unwrap(); + let _scope = PluginConfigDiscoveryScope::enter(temp.path(), &xdg); + let manifest_path = write_dynamic_manifest(&plugin_dir, "acme.worker"); + std::fs::write( + user_config_dir.join("plugins.toml"), + r#" +[[plugins.dynamic]] +manifest = "plugins/acme/relay-plugin.toml" +"#, + ) + .unwrap(); + std::fs::File::options() + .write(true) + .open(&manifest_path) + .unwrap() + .set_len(MAX_BOOTSTRAP_IDENTITY_FILE_BYTES + 1) + .unwrap(); + + let error = resolve_persistent_server_config(&GatewayOverrides::default()) + .unwrap_err() + .to_string(); + + assert!(error.contains("dynamic plugin manifest")); + assert!(error.contains("byte limit")); +} + +#[test] +fn persistent_server_resolution_rejects_oversized_sparse_dynamic_plugin_artifact() { + let temp = tempfile::tempdir().unwrap(); + let xdg = temp.path().join("xdg"); + let user_config_dir = xdg.join("nemo-relay"); + let plugin_dir = user_config_dir.join("plugins/acme"); + std::fs::create_dir_all(&plugin_dir).unwrap(); + let _scope = PluginConfigDiscoveryScope::enter(temp.path(), &xdg); + write_dynamic_manifest(&plugin_dir, "acme.worker"); + let plugins_toml_path = user_config_dir.join("plugins.toml"); + std::fs::write( + &plugins_toml_path, + r#" +[[plugins.dynamic]] +manifest = "plugins/acme/relay-plugin.toml" + +[plugins.policy.defaults] +startup = "required" +"#, + ) + .unwrap(); + write_dynamic_plugin_state(&plugins_toml_path, "acme.worker", true); + std::fs::File::options() + .write(true) + .open(plugin_dir.join("plugin.py")) + .unwrap() + .set_len(MAX_BOOTSTRAP_IDENTITY_FILE_BYTES + 1) + .unwrap(); + + let error = resolve_persistent_server_config(&GatewayOverrides::default()) + .unwrap_err() + .to_string(); + + assert!(error.contains("dynamic plugin artifact")); + assert!(error.contains("byte limit")); +} + +#[test] +fn bootstrap_hmac_key_rejects_corrupt_persistent_state() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("state/fingerprint-hmac.key"); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, b"short").unwrap(); + + let error = load_or_create_bootstrap_hmac_key_at(&path).unwrap_err(); + + assert!(error.to_string().contains("invalid length 5")); +} + +#[test] +fn persistent_server_resolution_rejects_project_specific_flags() { + let args = GatewayOverrides { + config: Some(PathBuf::from("project-config.toml")), + ..GatewayOverrides::default() + }; + + assert!( + resolve_persistent_server_config(&args) + .unwrap_err() + .to_string() + .contains("nemo-relay run") + ); +} + #[test] fn server_resolution_fails_when_required_enabled_dynamic_plugin_is_blocked_by_policy() { let temp = tempfile::tempdir().unwrap(); @@ -1470,9 +2326,9 @@ allowed = false ) .unwrap(); write_dynamic_plugin_state(&plugins_toml_path, "acme.worker", true); - let args = ServerArgs { + let args = GatewayOverrides { config: Some(config_path), - ..ServerArgs::default() + ..GatewayOverrides::default() }; let error = resolve_server_config(&args).unwrap_err().to_string(); @@ -1509,9 +2365,9 @@ startup = "required" .unwrap(); write_dynamic_plugin_state(&plugins_toml_path, "acme.worker", true); - let args = ServerArgs { + let args = GatewayOverrides { config: Some(config_path), - ..ServerArgs::default() + ..GatewayOverrides::default() }; let error = resolve_server_config(&args).unwrap_err().to_string(); @@ -1577,9 +2433,9 @@ attestation = "signature_required" .unwrap(); write_dynamic_plugin_state(&plugins_toml_path, "acme.worker", true); - let args = ServerArgs { + let args = GatewayOverrides { config: Some(config_path), - ..ServerArgs::default() + ..GatewayOverrides::default() }; let error = resolve_server_config(&args).unwrap_err().to_string(); @@ -1649,9 +2505,9 @@ fn server_resolution_fails_when_required_enabled_dynamic_plugin_has_wrong_truste .unwrap(); write_dynamic_plugin_state(&plugins_toml_path, "acme.worker", true); - let args = ServerArgs { + let args = GatewayOverrides { config: Some(config_path), - ..ServerArgs::default() + ..GatewayOverrides::default() }; let error = resolve_server_config(&args).unwrap_err().to_string(); @@ -1721,9 +2577,9 @@ fn server_resolution_fails_when_required_enabled_dynamic_plugin_has_malformed_si .unwrap(); write_dynamic_plugin_state(&plugins_toml_path, "acme.worker", true); - let args = ServerArgs { + let args = GatewayOverrides { config: Some(config_path), - ..ServerArgs::default() + ..GatewayOverrides::default() }; let error = resolve_server_config(&args).unwrap_err().to_string(); @@ -1766,11 +2622,11 @@ fn gateway_body_limit_defaults_are_stable() { assert_eq!( gateway.max_hook_payload_bytes, - crate::config::DEFAULT_MAX_HOOK_PAYLOAD_BYTES + crate::configuration::DEFAULT_MAX_HOOK_PAYLOAD_BYTES ); assert_eq!( gateway.max_passthrough_body_bytes, - crate::config::DEFAULT_MAX_PASSTHROUGH_BODY_BYTES + crate::configuration::DEFAULT_MAX_PASSTHROUGH_BODY_BYTES ); } @@ -1786,9 +2642,9 @@ fn gateway_body_limit_file_values_must_be_nonzero() { ), ] { std::fs::write(&path, format!("[gateway]\n{field} = 0\n")).unwrap(); - let args = ServerArgs { + let args = GatewayOverrides { config: Some(path.clone()), - ..ServerArgs::default() + ..GatewayOverrides::default() }; let error = resolve_server_config(&args).unwrap_err().to_string(); @@ -1803,7 +2659,7 @@ fn run_resolution_applies_all_run_overrides() { let temp = tempfile::tempdir().unwrap(); let config_path = isolated_config_path(&temp); std::fs::write(&config_path, "").unwrap(); - let command = RunCommand { + let command = RunOverrides { agent: Some(CodingAgent::Codex), config: Some(config_path), openai_base_url: Some("http://run-openai".into()), @@ -1844,7 +2700,7 @@ allowed = false ) .unwrap(); write_dynamic_plugin_state(&plugins_toml_path, "acme.worker", true); - let command = RunCommand { + let command = RunOverrides { agent: Some(CodingAgent::Codex), config: Some(config_path), openai_base_url: None, @@ -1868,9 +2724,9 @@ fn malformed_shared_config_reports_context() { let temp = tempfile::tempdir().unwrap(); let invalid_toml = temp.path().join("invalid.toml"); std::fs::write(&invalid_toml, "server = [").unwrap(); - let args = ServerArgs { + let args = GatewayOverrides { config: Some(invalid_toml), - ..ServerArgs::default() + ..GatewayOverrides::default() }; let error = resolve_server_config(&args).unwrap_err().to_string(); @@ -1879,9 +2735,9 @@ fn malformed_shared_config_reports_context() { let invalid_shape = temp.path().join("invalid-shape.toml"); std::fs::write(&invalid_shape, "upstream = \"not-a-table\"").unwrap(); - let args = ServerArgs { + let args = GatewayOverrides { config: Some(invalid_shape), - ..ServerArgs::default() + ..GatewayOverrides::default() }; let error = resolve_server_config(&args).unwrap_err().to_string(); @@ -1891,9 +2747,9 @@ fn malformed_shared_config_reports_context() { let plugin_config = temp.path().join("config-with-invalid-plugins.toml"); std::fs::write(&plugin_config, "").unwrap(); std::fs::write(temp.path().join("plugins.toml"), "version = [").unwrap(); - let args = ServerArgs { + let args = GatewayOverrides { config: Some(plugin_config), - ..ServerArgs::default() + ..GatewayOverrides::default() }; let error = resolve_server_config(&args).unwrap_err().to_string(); @@ -1945,3 +2801,52 @@ unknown_component = "error" Some("warn") ); } + +#[cfg(windows)] +fn set_test_windows_dacl(path: &std::path::Path, sddl: &str) { + use std::os::windows::ffi::OsStrExt; + use windows_sys::Win32::Foundation::LocalFree; + use windows_sys::Win32::Security::Authorization::{ + ConvertStringSecurityDescriptorToSecurityDescriptorW, SDDL_REVISION_1, + }; + use windows_sys::Win32::Security::{ + DACL_SECURITY_INFORMATION, PROTECTED_DACL_SECURITY_INFORMATION, PSECURITY_DESCRIPTOR, + SetFileSecurityW, + }; + + let sddl = std::ffi::OsStr::new(sddl) + .encode_wide() + .chain(Some(0)) + .collect::>(); + let mut descriptor: PSECURITY_DESCRIPTOR = std::ptr::null_mut(); + // SAFETY: The SDDL is NUL-terminated and the output pointer is valid. + assert_ne!( + unsafe { + ConvertStringSecurityDescriptorToSecurityDescriptorW( + sddl.as_ptr(), + SDDL_REVISION_1, + &mut descriptor, + std::ptr::null_mut(), + ) + }, + 0, + "{}", + std::io::Error::last_os_error() + ); + let path = path + .as_os_str() + .encode_wide() + .chain(Some(0)) + .collect::>(); + // SAFETY: The path and descriptor remain valid for the duration of the call. + let result = unsafe { + SetFileSecurityW( + path.as_ptr(), + DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION, + descriptor, + ) + }; + // SAFETY: The descriptor was allocated by ConvertStringSecurityDescriptor... above. + unsafe { LocalFree(descriptor.cast()) }; + assert_ne!(result, 0, "{}", std::io::Error::last_os_error()); +} diff --git a/crates/cli/tests/coverage/doctor_tests.rs b/crates/cli/tests/coverage/shared/doctor_tests.rs similarity index 89% rename from crates/cli/tests/coverage/doctor_tests.rs rename to crates/cli/tests/coverage/shared/doctor_tests.rs index f784fb065..5d06e5e2d 100644 --- a/crates/cli/tests/coverage/doctor_tests.rs +++ b/crates/cli/tests/coverage/shared/doctor_tests.rs @@ -2,13 +2,13 @@ // SPDX-License-Identifier: Apache-2.0 use super::*; -use std::ffi::OsString; use std::io::{Read, Write}; use std::net::TcpListener; use std::path::PathBuf; use std::sync::{Arc, Mutex}; -use crate::config::ResolvedDynamicPluginConfig; +use crate::configuration::ResolvedDynamicPluginConfig; +use crate::test_support::{EnvScope, accept_bounded}; fn start_doctor_http_capture_server() -> (String, Arc>, std::thread::JoinHandle<()>) { let listener = TcpListener::bind("127.0.0.1:0").unwrap(); @@ -16,7 +16,7 @@ fn start_doctor_http_capture_server() -> (String, Arc>, std::threa let body = Arc::new(Mutex::new(String::new())); let thread_body = Arc::clone(&body); let handle = std::thread::spawn(move || { - let (mut stream, _) = listener.accept().unwrap(); + let mut stream = accept_bounded(&listener); let mut data = Vec::new(); let mut buf = [0_u8; 1]; while !data.ends_with(b"\r\n\r\n") { @@ -44,48 +44,6 @@ fn start_doctor_http_capture_server() -> (String, Arc>, std::threa (url, body, handle) } -struct EnvScope { - _guard: std::sync::MutexGuard<'static, ()>, - values: Vec<(&'static str, Option)>, -} - -impl EnvScope { - fn set(values: &[(&'static str, Option<&std::ffi::OsStr>)]) -> Self { - let guard = crate::test_support::ENV_TEST_LOCK - .lock() - .unwrap_or_else(|error| error.into_inner()); - let previous = values - .iter() - .map(|(key, _)| (*key, std::env::var_os(key))) - .collect::>(); - for (key, value) in values { - unsafe { - match value { - Some(value) => std::env::set_var(key, value), - None => std::env::remove_var(key), - } - } - } - Self { - _guard: guard, - values: previous, - } - } -} - -impl Drop for EnvScope { - fn drop(&mut self) { - for (key, value) in self.values.drain(..) { - unsafe { - match value { - Some(value) => std::env::set_var(key, value), - None => std::env::remove_var(key), - } - } - } - } -} - fn empty_report() -> DoctorReport { DoctorReport { schema_version: 1, @@ -201,7 +159,7 @@ fn exit_code_fails_when_an_installed_host_plugin_is_unready() { let mut report = empty_report(); report .host_plugins - .push(crate::plugin_install::HostPluginReadiness { + .push(crate::installation::marketplace::HostPluginReadiness { host: "codex".into(), remediation: "nemo-relay install codex --force".into(), state_path: PathBuf::from("/tmp/codex.json"), @@ -209,7 +167,7 @@ fn exit_code_fails_when_an_installed_host_plugin_is_unready() { plugin: Some(PathBuf::from( "/tmp/codex-marketplace/plugins/nemo-relay-plugin", )), - checks: vec![crate::plugin_install::HostPluginReadinessCheck { + checks: vec![crate::installation::marketplace::HostPluginReadinessCheck { name: "Host CLI".into(), ok: false, details: "required `codex` CLI was not found on PATH".into(), @@ -222,7 +180,7 @@ fn exit_code_fails_when_an_installed_host_plugin_is_unready() { assert_eq!(exit_code(&report), 1); let rendered = format_human(&report); - assert!(rendered.contains("Host plugins")); + assert!(rendered.contains("Persistent integrations")); assert!(rendered.contains("repair: nemo-relay install codex --force")); let json: serde_json::Value = serde_json::from_str(&format_json(&report).unwrap()).unwrap(); assert_eq!(json["schema_version"], 1); @@ -355,6 +313,27 @@ fn format_human_reports_config_resolution_failure() { assert!(rendered.contains("Some checks FAILED")); } +#[tokio::test] +async fn agents_report_surfaces_merged_config_resolution_errors() { + let temp = tempfile::tempdir().unwrap(); + let config_home = temp.path().join("config"); + let config = config_home.join("nemo-relay").join("config.toml"); + std::fs::create_dir_all(config.parent().unwrap()).unwrap(); + std::fs::write(&config, "[upstream\n").unwrap(); + let _env = EnvScope::set(&[ + ("XDG_CONFIG_HOME", Some(config_home.as_os_str())), + ( + "NEMO_RELAY_CONFIG_SCOPE", + Some(std::ffi::OsStr::new("user")), + ), + ]); + + let error = agents_report().await.unwrap_err().to_string(); + + assert!(error.contains("config"), "{error}"); + assert!(error.contains("TOML"), "{error}"); +} + #[test] fn format_human_distinguishes_pass_with_warnings_from_clean_pass() { let mut report = empty_report(); @@ -620,10 +599,12 @@ fn collect_configuration_uses_xdg_global_path_and_renders_resolution_branches() #[test] fn agent_helper_statuses_cover_configured_target_and_hook_paths() { - assert_eq!(command_executable("codex --full-auto"), "codex"); - assert_eq!(command_executable(""), ""); assert_eq!( - agent_command(CodingAgent::ClaudeCode, &AgentConfigs::default(), "claude"), + crate::process::command_argv("codex --full-auto"), + ["codex", "--full-auto"] + ); + assert_eq!( + agent_command(CodingAgent::ClaudeCode, &AgentConfigs::default()), "claude" ); assert_eq!( @@ -645,34 +626,20 @@ fn agent_helper_statuses_cover_configured_target_and_hook_paths() { assert!(agent_configured(CodingAgent::Hermes, &agents)); assert_eq!(configured_agent_names(&agents), vec!["hermes".to_string()]); assert_eq!( - hook_status(CodingAgent::ClaudeCode, &agents, true), + hook_status(CodingAgent::ClaudeCode, &agents), (Status::Pass, "hooks: injected during run".into()) ); assert_eq!( - hook_status(CodingAgent::Codex, &agents, true), + hook_status(CodingAgent::Codex, &agents), (Status::Pass, "hooks: injected during run".into()) ); - let temp = tempfile::tempdir().unwrap(); - let hook = temp.path().join("hooks.yaml"); - std::fs::write(&hook, "cmd: nemo-relay hook-forward hermes\n").unwrap(); - let (status, details) = hook_file_status(Ok(hook.clone()), CodingAgent::Hermes, true, "hooks"); - assert_eq!(status, Status::Pass); - assert!(details.contains(hook.to_str().unwrap())); - - std::fs::write(&hook, "cmd: custom\n").unwrap(); - let (status, details) = hook_file_status(Ok(hook.clone()), CodingAgent::Hermes, true, "hooks"); - assert_eq!(status, Status::Fail); - assert!(details.contains("missing NeMo Relay hook")); - let (status, _) = hook_file_status(Ok(hook), CodingAgent::Hermes, false, "hooks"); - assert_eq!(status, Status::Info); - - let agents = AgentConfigs::default(); - let (status, details) = hook_status(CodingAgent::Hermes, &agents, true); - assert_eq!(status, Status::Fail); - assert!(details.contains("not installed")); - let (status, details) = hook_status(CodingAgent::Hermes, &agents, false); - assert_eq!(status, Status::Info); - assert!(details.contains("not configured")); + assert_eq!( + hook_status(CodingAgent::Hermes, &AgentConfigs::default()), + ( + Status::Pass, + "hooks: injected through an isolated HERMES_HOME during run".into() + ) + ); } #[test] @@ -702,7 +669,10 @@ fn collect_completions_reports_shell_specific_paths() { #[test] fn collect_environment_and_completions_cover_missing_home_and_unknown_shell() { - let _env = EnvScope::set(&[("SHELL", Some(std::ffi::OsStr::new("/opt/bin/elvish")))]); + let _env = EnvScope::set(&[ + ("SHELL", Some(std::ffi::OsStr::new("/opt/bin/elvish"))), + ("COMSPEC", Some(std::ffi::OsStr::new("C:/opt/bin/elvish"))), + ]); let environment = collect_environment(); assert_eq!(environment.shell.as_deref(), Some("elvish")); @@ -724,7 +694,7 @@ fn collect_environment_and_completions_cover_missing_home_and_unknown_shell() { async fn collect_agents_filters_target_and_records_version() { let temp = tempfile::tempdir().unwrap(); let codex = temp.path().join("codex"); - std::fs::write(&codex, "#!/bin/sh\nprintf 'codex 1.2.3\\n'\n").unwrap(); + std::fs::write(&codex, "#!/bin/sh\nprintf 'codex-cli 0.143.0\\n'\n").unwrap(); make_executable(&codex); let mut resolved = ResolvedConfig::default(); @@ -735,7 +705,84 @@ async fn collect_agents_filters_target_and_records_version() { assert_eq!(agents[0].name, "codex"); assert_eq!(agents[0].status, Status::Pass); assert_eq!(agents[0].path.as_deref(), Some(codex.as_path())); - assert_eq!(agents[0].version.as_deref(), Some("codex 1.2.3")); + assert_eq!(agents[0].version.as_deref(), Some("codex-cli 0.143.0")); +} + +#[cfg(unix)] +#[tokio::test] +async fn collect_agents_preserves_wrapper_argv_for_version_validation() { + let temp = tempfile::tempdir().unwrap(); + let wrapper = temp.path().join("npx"); + std::fs::write( + &wrapper, + "#!/bin/sh\n[ \"$1\" = codex ] && [ \"$2\" = --version ] || exit 9\nprintf 'codex-cli 0.143.0\\n'\n", + ) + .unwrap(); + make_executable(&wrapper); + + let mut resolved = ResolvedConfig::default(); + resolved.agents.codex.command = Some(format!("{} codex", wrapper.display())); + let agents = collect_agents(Some(CodingAgent::Codex), &resolved).await; + + assert_eq!(agents[0].status, Status::Pass); + assert_eq!(agents[0].path.as_deref(), Some(wrapper.as_path())); + assert_eq!(agents[0].version.as_deref(), Some("codex-cli 0.143.0")); +} + +#[cfg(unix)] +#[tokio::test] +#[allow(clippy::await_holding_lock)] +async fn collect_agents_distinguishes_required_and_optional_version_failures() { + let temp = tempfile::tempdir().unwrap(); + let codex = temp.path().join("codex"); + std::fs::write(&codex, "#!/bin/sh\nprintf 'codex-cli 0.1.0\\n'\n").unwrap(); + make_executable(&codex); + + let mut configured = ResolvedConfig::default(); + configured.agents.codex.command = Some(codex.display().to_string()); + let required = collect_agents(Some(CodingAgent::Codex), &configured).await; + assert_eq!(required[0].status, Status::Fail); + assert!(required[0].annotation.contains("is unsupported")); + + let _environment = EnvScope::set(&[("PATH", Some(temp.path().as_os_str()))]); + let discovered = collect_agents(None, &ResolvedConfig::default()).await; + let optional = discovered + .iter() + .find(|agent| agent.name == "codex") + .unwrap(); + assert_eq!(optional.status, Status::Warn); + assert!(optional.annotation.contains("is unsupported")); + + std::fs::write(&codex, "#!/bin/sh\nexit 0\n").unwrap(); + make_executable(&codex); + let required = collect_agents(Some(CodingAgent::Codex), &configured).await; + assert_eq!(required[0].status, Status::Fail); + assert!( + required[0] + .annotation + .contains("could not determine version") + ); + + let discovered = collect_agents(None, &ResolvedConfig::default()).await; + let optional = discovered + .iter() + .find(|agent| agent.name == "codex") + .unwrap(); + assert_eq!(optional.status, Status::Warn); + assert!(optional.annotation.contains("could not determine version")); +} + +#[test] +fn hermes_hook_status_reports_actionable_persistent_diagnosis_failures() { + let temp = tempfile::tempdir().unwrap(); + let mut agents = AgentConfigs::default(); + agents.hermes.hooks_path = Some(temp.path().join("missing-config.yaml")); + + let (status, details) = hook_status(CodingAgent::Hermes, &agents); + + assert_eq!(status, Status::Fail); + assert!(details.contains("persistent MCP/hooks"), "{details}"); + assert!(details.contains("install hermes --force"), "{details}"); } #[cfg(unix)] @@ -745,10 +792,24 @@ async fn probe_version_returns_none_for_empty_output_and_spawn_failures() { let quiet = temp.path().join("quiet-agent"); std::fs::write(&quiet, "#!/bin/sh\nexit 0\n").unwrap(); make_executable(&quiet); + let failed = temp.path().join("failed-agent"); + std::fs::write(&failed, "#!/bin/sh\nprintf 'codex-cli 99.0.0\\n'\nexit 7\n").unwrap(); + make_executable(&failed); - assert_eq!(probe_version(&quiet).await, None); assert_eq!( - probe_version(&temp.path().join("missing-agent")).await, + probe_version(&[quiet.display().to_string(), "--version".into()]).await, + None + ); + assert_eq!( + probe_version(&[failed.display().to_string(), "--version".into()]).await, + None + ); + assert_eq!( + probe_version(&[ + temp.path().join("missing-agent").display().to_string(), + "--version".into(), + ]) + .await, None ); } @@ -795,12 +856,20 @@ fn configuration_and_path_helpers_cover_direct_paths_and_fallbacks() { assert!(info.global.path.starts_with(&home)); assert_eq!(info.configured_agents, vec!["codex".to_string()]); - assert_eq!(which_on_path("definitely-missing"), None); - assert_eq!(which_command("/definitely/missing"), None); - let binary = temp.path().join("agent-bin"); + assert_eq!( + crate::process::resolve_executable("definitely-missing"), + None + ); + assert_eq!( + crate::process::resolve_executable("/definitely/missing"), + None + ); + let binary = temp + .path() + .join(format!("agent-bin{}", std::env::consts::EXE_SUFFIX)); std::fs::write(&binary, "").unwrap(); assert_eq!( - which_command(binary.to_str().unwrap()).as_deref(), + crate::process::resolve_executable(binary.to_str().unwrap()).as_deref(), Some(binary.as_path()) ); } @@ -904,52 +973,6 @@ fn check_directory_reports_pass_warn_and_fail() { assert_eq!(fail.status, Status::Fail); } -#[test] -fn hook_file_status_covers_resolution_and_missing_paths() { - let resolution_error = hook_file_status( - Err(CliError::Config("bad path".into())), - CodingAgent::Hermes, - true, - "hooks", - ); - assert_eq!(resolution_error.0, Status::Fail); - assert!(resolution_error.1.contains("could not resolve path")); - - let missing = tempfile::tempdir().unwrap().path().join("missing.yaml"); - let (status, details) = - hook_file_status(Ok(missing.clone()), CodingAgent::Hermes, true, "hooks"); - assert_eq!(status, Status::Fail); - assert!(details.contains("missing")); - - let (status, details) = hook_file_status(Ok(missing), CodingAgent::Hermes, false, "hooks"); - assert_eq!(status, Status::Info); - assert!(details.contains("missing")); -} - -#[test] -fn hook_file_status_covers_plain_files_and_read_errors() { - let temp = tempfile::tempdir().unwrap(); - let hooks_path = temp.path().join("config.yaml"); - std::fs::write(&hooks_path, "hooks:\n PreToolUse: []\n").unwrap(); - - let (status, details) = - hook_file_status(Ok(hooks_path.clone()), CodingAgent::Hermes, true, "hooks"); - assert_eq!(status, Status::Fail); - assert!(details.contains("missing NeMo Relay hook")); - - let (status, details) = - hook_file_status(Ok(hooks_path.clone()), CodingAgent::Hermes, false, "hooks"); - assert_eq!(status, Status::Info); - assert!(details.contains("no NeMo Relay hook")); - - let unreadable_path = temp.path().join("hooks-dir"); - std::fs::create_dir(&unreadable_path).unwrap(); - let (status, details) = - hook_file_status(Ok(unreadable_path), CodingAgent::Hermes, false, "hooks"); - assert_eq!(status, Status::Fail); - assert!(details.contains("could not read")); -} - #[tokio::test] async fn collect_observability_warns_for_missing_atif_dir_without_creating_it() { let temp = tempfile::tempdir().unwrap(); @@ -1290,7 +1313,7 @@ async fn atof_http_and_websocket_probes_report_failure_branches() { let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let url = format!("http://{}", listener.local_addr().unwrap()); let handle = std::thread::spawn(move || { - let (mut stream, _) = listener.accept().unwrap(); + let mut stream = accept_bounded(&listener); let mut buf = [0_u8; 1024]; let _ = stream.read(&mut buf).unwrap(); stream @@ -1346,7 +1369,7 @@ async fn atof_http_and_websocket_timeout_errors_are_reported() { let http_listener = TcpListener::bind("127.0.0.1:0").unwrap(); let http_url = format!("http://{}", http_listener.local_addr().unwrap()); let http_handle = std::thread::spawn(move || { - let (_stream, _) = http_listener.accept().unwrap(); + let _stream = accept_bounded(&http_listener); std::thread::sleep(std::time::Duration::from_millis(75)); }); @@ -1378,7 +1401,7 @@ async fn atof_http_and_websocket_timeout_errors_are_reported() { let ws_listener = TcpListener::bind("127.0.0.1:0").unwrap(); let ws_url = format!("ws://{}", ws_listener.local_addr().unwrap()); let ws_handle = std::thread::spawn(move || { - let (_stream, _) = ws_listener.accept().unwrap(); + let _stream = accept_bounded(&ws_listener); std::thread::sleep(std::time::Duration::from_millis(75)); }); let websocket_timeout = probe_atof_websocket( @@ -1399,7 +1422,7 @@ async fn probe_http_named_warns_on_http_errors() { let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let url = format!("http://{}", listener.local_addr().unwrap()); let handle = std::thread::spawn(move || { - let (mut stream, _) = listener.accept().unwrap(); + let mut stream = accept_bounded(&listener); let mut buf = [0_u8; 1024]; let _ = stream.read(&mut buf).unwrap(); stream @@ -1418,7 +1441,7 @@ async fn http_probe_passes_success_and_ndjson_upload_success() { let success_listener = TcpListener::bind("127.0.0.1:0").unwrap(); let success_url = format!("http://{}", success_listener.local_addr().unwrap()); let success_handle = std::thread::spawn(move || { - let (mut stream, _) = success_listener.accept().unwrap(); + let mut stream = accept_bounded(&success_listener); let mut buf = [0_u8; 1024]; let _ = stream.read(&mut buf).unwrap(); stream @@ -1660,26 +1683,3 @@ fn format_agents_json_matches_doctor_agents_shape() { assert_eq!(parsed[0]["version"], "2.1.4"); assert_eq!(parsed[0]["path"], "/opt/homebrew/bin/claude"); } - -#[test] -fn claude_hook_floor_warning_pins_version_boundary() { - // 2.1.116 is the first Claude Code whose plugin hook whitelist accepts UserPromptExpansion; - // 2.1.114 is the newest published version that rejects it (2.1.115 was never published). - let cases = [ - ("2.1.114 (Claude Code)", true), - ("2.1.116 (Claude Code)", false), - ("2.1.206 (Claude Code)", false), - ("2.0.999 (Claude Code)", true), - ("3.0.0 (Claude Code)", false), - ("2.1.116-beta (Claude Code)", false), - ("not a version", false), - ("", false), - ]; - for (version, expect_warning) in cases { - assert_eq!( - claude_hook_floor_warning(version).is_some(), - expect_warning, - "unexpected floor verdict for {version:?}" - ); - } -} diff --git a/crates/cli/tests/coverage/shared/file_io_tests.rs b/crates/cli/tests/coverage/shared/file_io_tests.rs new file mode 100644 index 000000000..f61d2b73b --- /dev/null +++ b/crates/cli/tests/coverage/shared/file_io_tests.rs @@ -0,0 +1,227 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use std::fs::OpenOptions; + +use tempfile::tempdir; + +use super::*; + +#[test] +fn lock_attempts_distinguish_contention_from_errors() { + let directory = tempdir().unwrap(); + let path = directory.path().join("advisory.lock"); + let owner = OpenOptions::new() + .create(true) + .truncate(false) + .read(true) + .write(true) + .open(&path) + .unwrap(); + let waiter = OpenOptions::new() + .read(true) + .write(true) + .open(&path) + .unwrap(); + + assert_eq!(try_lock_exclusive(&owner).unwrap(), LockAttempt::Acquired); + assert_eq!(try_lock_exclusive(&waiter).unwrap(), LockAttempt::Contended); + assert_eq!(try_lock_shared(&waiter).unwrap(), LockAttempt::Contended); + + fs2::FileExt::unlock(&owner).unwrap(); + assert_eq!(try_lock_shared(&waiter).unwrap(), LockAttempt::Acquired); + fs2::FileExt::unlock(&waiter).unwrap(); +} + +#[cfg(unix)] +#[test] +fn private_atomic_write_ignores_a_permissive_umask() { + use std::os::unix::fs::PermissionsExt; + + struct UmaskGuard(libc::mode_t); + impl Drop for UmaskGuard { + fn drop(&mut self) { + // SAFETY: Restores the process umask while the environment-test mutex is held. + unsafe { libc::umask(self.0) }; + } + } + + let _lock = crate::test_support::ENV_TEST_LOCK + .lock() + .unwrap_or_else(|error| error.into_inner()); + // SAFETY: The process-global umask is serialized by the environment-test mutex. + let previous = unsafe { libc::umask(0) }; + let _guard = UmaskGuard(previous); + let directory = tempdir().unwrap(); + let path = directory.path().join("secret.toml"); + + atomic_write_private(&path, b"secret\n").unwrap(); + + assert_eq!( + std::fs::metadata(path).unwrap().permissions().mode() & 0o777, + 0o600 + ); +} + +#[cfg(unix)] +#[test] +fn ordinary_atomic_write_preserves_existing_permissions() { + use std::os::unix::fs::PermissionsExt; + + let directory = tempdir().unwrap(); + let path = directory.path().join("config.toml"); + std::fs::write(&path, b"old\n").unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o640)).unwrap(); + + atomic_write(&path, b"new\n").unwrap(); + + assert_eq!( + std::fs::metadata(path).unwrap().permissions().mode() & 0o777, + 0o640 + ); +} + +#[cfg(windows)] +#[test] +fn private_atomic_write_does_not_inherit_a_broad_parent_dacl() { + let directory = tempdir().unwrap(); + set_windows_dacl(directory.path(), "D:P(A;;FA;;;WD)"); + let path = directory.path().join("secret.toml"); + + atomic_write_private(&path, b"old-secret\n").unwrap(); + atomic_write_private(&path, b"new-secret\n").unwrap(); + + let parent = windows_sddl(directory.path()); + let file = windows_sddl(&path); + assert!( + parent.contains("WD"), + "parent DACL was not broadly readable: {parent}" + ); + assert!(file.contains("D:P"), "file DACL is not protected: {file}"); + assert!( + file.contains("OW") || file.contains("S-1-3-4"), + "file DACL does not grant its owner access: {file}" + ); + assert!( + !file.contains("WD"), + "file inherited Everyone access: {file}" + ); +} + +#[cfg(windows)] +#[test] +fn failed_windows_atomic_replacement_keeps_the_original_target() { + use std::os::windows::fs::OpenOptionsExt; + + let directory = tempdir().unwrap(); + let path = directory.path().join("config.toml"); + std::fs::write(&path, b"original\n").unwrap(); + let held = OpenOptions::new() + .read(true) + .share_mode(0) + .open(&path) + .unwrap(); + + let error = atomic_write(&path, b"replacement\n").unwrap_err(); + drop(held); + + assert!(error.contains("failed to replace"), "{error}"); + assert_eq!(std::fs::read(&path).unwrap(), b"original\n"); + assert!(std::fs::read_dir(directory.path()).unwrap().all(|entry| { + !entry + .unwrap() + .file_name() + .to_string_lossy() + .ends_with(".tmp") + })); +} + +#[cfg(windows)] +fn set_windows_dacl(path: &std::path::Path, sddl: &str) { + use windows_sys::Win32::Foundation::LocalFree; + use windows_sys::Win32::Security::Authorization::{ + ConvertStringSecurityDescriptorToSecurityDescriptorW, SDDL_REVISION_1, + }; + use windows_sys::Win32::Security::{ + DACL_SECURITY_INFORMATION, PROTECTED_DACL_SECURITY_INFORMATION, PSECURITY_DESCRIPTOR, + SetFileSecurityW, + }; + + let sddl = windows_wide(sddl); + let mut descriptor: PSECURITY_DESCRIPTOR = std::ptr::null_mut(); + // SAFETY: The SDDL is NUL-terminated and the output pointer is valid. + assert_ne!( + unsafe { + ConvertStringSecurityDescriptorToSecurityDescriptorW( + sddl.as_ptr(), + SDDL_REVISION_1, + &mut descriptor, + std::ptr::null_mut(), + ) + }, + 0, + "{}", + std::io::Error::last_os_error() + ); + let path = windows_wide(path.as_os_str()); + // SAFETY: The path and descriptor are valid for the duration of the call. + let result = unsafe { + SetFileSecurityW( + path.as_ptr(), + DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION, + descriptor, + ) + }; + // SAFETY: The descriptor was allocated by ConvertStringSecurityDescriptor... above. + unsafe { LocalFree(descriptor.cast()) }; + assert_ne!(result, 0, "{}", std::io::Error::last_os_error()); +} + +#[cfg(windows)] +fn windows_sddl(path: &std::path::Path) -> String { + use windows_sys::Win32::Foundation::LocalFree; + use windows_sys::Win32::Security::Authorization::{ + ConvertSecurityDescriptorToStringSecurityDescriptorW, SDDL_REVISION_1, + }; + use windows_sys::Win32::Security::{DACL_SECURITY_INFORMATION, PSECURITY_DESCRIPTOR}; + + let mut descriptor = read_windows_dacl(path).unwrap(); + let mut rendered = std::ptr::null_mut(); + let mut rendered_len = 0; + // SAFETY: The self-relative descriptor buffer is valid, and both output pointers reference + // writable storage. The returned UTF-16 allocation is released below. + assert_ne!( + unsafe { + ConvertSecurityDescriptorToStringSecurityDescriptorW( + descriptor.as_mut_ptr().cast::() as PSECURITY_DESCRIPTOR, + SDDL_REVISION_1, + DACL_SECURITY_INFORMATION, + &mut rendered, + &mut rendered_len, + ) + }, + 0, + "{}", + std::io::Error::last_os_error() + ); + // SAFETY: The API returned `rendered_len` initialized UTF-16 code units. + let value = String::from_utf16_lossy(unsafe { + std::slice::from_raw_parts(rendered, rendered_len as usize) + }); + // SAFETY: `rendered` was allocated by ConvertSecurityDescriptor... above. + unsafe { LocalFree(rendered.cast()) }; + value +} + +#[cfg(windows)] +#[test] +fn windows_lock_violation_is_normalized_as_contention() { + let error = std::io::Error::from_raw_os_error( + windows_sys::Win32::Foundation::ERROR_LOCK_VIOLATION as i32, + ); + + assert_eq!( + normalize_lock_attempt(Err(error)).unwrap(), + LockAttempt::Contended + ); +} diff --git a/crates/cli/tests/coverage/shared/gateway_client_tests.rs b/crates/cli/tests/coverage/shared/gateway_client_tests.rs new file mode 100644 index 000000000..842691b82 --- /dev/null +++ b/crates/cli/tests/coverage/shared/gateway_client_tests.rs @@ -0,0 +1,238 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use std::io::{Read, Write}; +use std::net::TcpListener; +use std::sync::mpsc; +use std::thread; +use std::time::Duration; + +use super::*; +use crate::test_support::{EnvScope, header, read_headers}; + +fn serve_once(response: &[u8]) -> (String, mpsc::Receiver>, thread::JoinHandle<()>) { + let response = response.to_vec(); + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let url = format!("http://{}", listener.local_addr().unwrap()); + let (sender, receiver) = mpsc::channel(); + let server = thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + stream + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + let mut request = Vec::new(); + let mut buffer = [0_u8; 1024]; + while !request.windows(4).any(|window| window == b"\r\n\r\n") { + match stream.read(&mut buffer) { + Ok(0) => break, + Ok(read) => request.extend_from_slice(&buffer[..read]), + Err(error) + if matches!( + error.kind(), + std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut + ) => + { + break; + } + Err(error) => panic!("failed to read request: {error}"), + } + } + let _ = sender.send(request); + stream.write_all(&response).unwrap(); + }); + (url, receiver, server) +} + +fn serve_verified_shutdown( + key: crate::configuration::BootstrapChallengeKey, + response: &[u8], +) -> (String, mpsc::Receiver, thread::JoinHandle<()>) { + let response = response.to_vec(); + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let url = format!("http://{}", listener.local_addr().unwrap()); + let (sender, receiver) = mpsc::channel(); + let server = thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + stream + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + let challenge = read_headers(&mut stream); + let nonce = header(&challenge, "x-nemo-relay-bootstrap-nonce"); + let proof = key.proof("fingerprint", &nonce); + let body = format!( + "{{\"status\":\"ok\",\"service\":\"nemo-relay\",\"version\":\"{}\",\"bootstrap_protocol\":{},\"instance_id\":\"test-instance\"}}", + env!("CARGO_PKG_VERSION"), + BOOTSTRAP_PROTOCOL_VERSION + ); + stream + .write_all( + format!( + "HTTP/1.1 200 OK\r\nX-NeMo-Relay-Bootstrap-Proof: {proof}\r\nContent-Length: {}\r\nConnection: keep-alive\r\n\r\n{body}", + body.len() + ) + .as_bytes(), + ) + .unwrap(); + let _ = sender.send(read_headers(&mut stream)); + stream.write_all(&response).unwrap(); + }); + (url, receiver, server) +} + +#[test] +fn shutdown_request_sends_the_private_token_and_accepts_no_content() { + let temp = tempfile::tempdir().unwrap(); + let _environment = EnvScope::set(&[ + ("XDG_CONFIG_HOME", Some(temp.path().as_os_str())), + ("HOME", Some(temp.path().as_os_str())), + ]); + let key = crate::configuration::BootstrapChallengeKey::load().unwrap(); + let (url, request, server) = serve_verified_shutdown( + key, + b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + ); + + request_shutdown(&url, "fingerprint", "private-token").unwrap(); + + let request = request.recv_timeout(Duration::from_secs(2)).unwrap(); + assert!( + request.starts_with("POST /bootstrap/shutdown HTTP/1.1"), + "{request}" + ); + assert!( + request.contains("X-NeMo-Relay-Bootstrap-Token: private-token"), + "{request}" + ); + server.join().unwrap(); +} + +#[test] +fn shutdown_request_reports_rejection_without_hiding_the_status() { + let temp = tempfile::tempdir().unwrap(); + let _environment = EnvScope::set(&[ + ("XDG_CONFIG_HOME", Some(temp.path().as_os_str())), + ("HOME", Some(temp.path().as_os_str())), + ]); + let key = crate::configuration::BootstrapChallengeKey::load().unwrap(); + let (url, _, server) = serve_verified_shutdown( + key, + b"HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + ); + + let error = request_shutdown(&url, "fingerprint", "wrong-token").unwrap_err(); + + assert!(error.contains("rejected shutdown"), "{error}"); + assert!(error.contains("HTTP/1.1 403 Forbidden"), "{error}"); + server.join().unwrap(); +} + +#[test] +fn shutdown_request_rejects_a_malformed_http_response() { + let temp = tempfile::tempdir().unwrap(); + let _environment = EnvScope::set(&[ + ("XDG_CONFIG_HOME", Some(temp.path().as_os_str())), + ("HOME", Some(temp.path().as_os_str())), + ]); + let key = crate::configuration::BootstrapChallengeKey::load().unwrap(); + let (url, _, server) = serve_verified_shutdown(key, b"not-http"); + + let error = request_shutdown(&url, "fingerprint", "private-token").unwrap_err(); + + assert!(error.contains("malformed shutdown response"), "{error}"); + server.join().unwrap(); +} + +#[test] +fn shutdown_request_reports_connection_failure() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let url = format!("http://{}", listener.local_addr().unwrap()); + drop(listener); + + let error = request_shutdown(&url, "fingerprint", "private-token").unwrap_err(); + + assert!(error.contains("failed to connect"), "{error}"); +} + +#[test] +fn health_probe_classifies_invalid_and_malformed_endpoints_as_unavailable_or_foreign() { + assert_eq!(probe("not a URL", None), RelayHealth::Unavailable); + + let (url, _, server) = serve_once(b"not-http"); + assert_eq!(probe(&url, None), RelayHealth::Foreign); + server.join().unwrap(); + + let body = format!( + r#"{{"status":"starting","service":"nemo-relay","version":"{}","bootstrap_protocol":{}}}"#, + env!("CARGO_PKG_VERSION"), + BOOTSTRAP_PROTOCOL_VERSION + ); + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + let (url, _, server) = serve_once(response.as_bytes()); + assert_eq!(probe(&url, None), RelayHealth::Foreign); + server.join().unwrap(); +} + +#[test] +fn loopback_helpers_normalize_localhost_and_ipv6_authorities() { + assert_eq!( + loopback_bind("http://localhost:47632").unwrap(), + "127.0.0.1:47632".parse().unwrap() + ); + assert_eq!(loopback_authority("::1", 47632), "[::1]:47632"); +} + +#[test] +fn verified_hook_payload_is_not_sent_before_the_tls_tunnel_is_authenticated() { + let temp = tempfile::tempdir().unwrap(); + let _environment = EnvScope::set(&[ + ("XDG_CONFIG_HOME", Some(temp.path().as_os_str())), + ("HOME", Some(temp.path().as_os_str())), + ]); + crate::configuration::BootstrapChallengeKey::load().unwrap(); + crate::gateway::tls::RelayTlsIdentity::load_or_create().unwrap(); + let (url, request, server) = serve_once( + b"HTTP/1.1 101 Switching Protocols\r\nConnection: upgrade\r\nUpgrade: nemo-relay-tls\r\nContent-Length: 0\r\n\r\n", + ); + + let error = post_verified( + &url, + "fingerprint", + "/hooks/codex", + &[], + b"secret-hook-payload", + Duration::from_secs(2), + 1024, + ) + .unwrap_err(); + + let request = request.recv_timeout(Duration::from_secs(2)).unwrap(); + assert!( + !request + .windows(19) + .any(|window| window == b"secret-hook-payload") + ); + assert!(error.to_string().contains("authenticated Relay TLS tunnel")); + server.join().unwrap(); +} + +#[test] +fn verified_transport_reuses_loaded_bootstrap_credentials() { + let temp = tempfile::tempdir().unwrap(); + let _environment = EnvScope::set(&[ + ("XDG_CONFIG_HOME", Some(temp.path().as_os_str())), + ("HOME", Some(temp.path().as_os_str())), + ]); + crate::configuration::BootstrapChallengeKey::load().unwrap(); + crate::gateway::tls::RelayTlsIdentity::load_or_create().unwrap(); + + let first_key = cached_bootstrap_challenge_key().unwrap(); + let second_key = cached_bootstrap_challenge_key().unwrap(); + assert!(std::sync::Arc::ptr_eq(&first_key, &second_key)); + + let first_identity = cached_tls_identity().unwrap(); + let second_identity = cached_tls_identity().unwrap(); + assert!(std::sync::Arc::ptr_eq(&first_identity, &second_identity)); +} diff --git a/crates/cli/tests/coverage/gateway_tests.rs b/crates/cli/tests/coverage/shared/gateway_tests.rs similarity index 76% rename from crates/cli/tests/coverage/gateway_tests.rs rename to crates/cli/tests/coverage/shared/gateway_tests.rs index 18511e67b..a55a5f738 100644 --- a/crates/cli/tests/coverage/gateway_tests.rs +++ b/crates/cli/tests/coverage/shared/gateway_tests.rs @@ -2,15 +2,16 @@ // SPDX-License-Identifier: Apache-2.0 use super::*; -use crate::alignment::GatewayRouteKind; -use crate::config::GatewayConfig; +use crate::agents::shared::alignment::GatewayRouteKind; +use crate::configuration::GatewayConfig; use crate::server::AppState; -use crate::session::{LlmGatewayStart, SessionManager}; +use crate::sessions::{LlmGatewayStart, SessionManager}; use axum::body::Body; use axum::extract::State; use axum::http::{HeaderMap, HeaderValue, Method, Request, StatusCode, header}; use http_body_util::BodyExt; use reqwest::Client; +use serde_json::Map; use tokio::io::{AsyncReadExt, AsyncWriteExt}; fn test_http_client() -> Client { @@ -19,31 +20,87 @@ fn test_http_client() -> Client { #[test] fn removes_hop_by_hop_headers() { - assert!(!should_forward_request_header(&HeaderName::from_static( - "connection" - ))); - assert!(!should_forward_request_header(&HeaderName::from_static( - "host" - ))); - assert!(should_forward_request_header(&HeaderName::from_static( - "authorization" - ))); - assert!(!should_record_header(&HeaderName::from_static( - "authorization" - ))); - assert!(!should_record_header(&HeaderName::from_static("x-api-key"))); - assert!(!should_record_header(&HeaderName::from_static( - "anthropic-api-key" - ))); + let headers = HeaderMap::new(); + assert!(!should_forward_request_header( + &HeaderName::from_static("connection"), + &headers + )); + assert!(!should_forward_request_header( + &HeaderName::from_static("host"), + &headers + )); + assert!(!should_forward_request_header( + &HeaderName::from_static(crate::configuration::BOOTSTRAP_CLIENT_TOKEN_HEADER), + &headers + )); + assert!(should_forward_request_header( + &HeaderName::from_static("authorization"), + &headers + )); + assert!(!should_record_header( + &HeaderName::from_static("authorization"), + &headers + )); + assert!(!should_record_header( + &HeaderName::from_static("x-api-key"), + &headers + )); + assert!(!should_record_header( + &HeaderName::from_static("anthropic-api-key"), + &headers + )); // Additional credential aliases must not appear in observability metadata: // `cookie` carries session credentials; `api-key` is the generic alias used by some providers // (e.g., Azure OpenAI). Without these, secrets would leak into `LlmRequest.headers` and any // downstream exporter that mirrors them (ATIF, OpenInference span attributes). - assert!(!should_record_header(&HeaderName::from_static("cookie"))); - assert!(!should_record_header(&HeaderName::from_static("api-key"))); - assert!(should_record_header(&HeaderName::from_static( - "x-request-id" - ))); + assert!(!should_record_header( + &HeaderName::from_static("cookie"), + &headers + )); + assert!(!should_record_header( + &HeaderName::from_static("api-key"), + &headers + )); + assert!(should_record_header( + &HeaderName::from_static("x-request-id"), + &headers + )); + + let mut connection_headers = HeaderMap::new(); + connection_headers.insert( + header::CONNECTION, + HeaderValue::from_static("x-private, upgrade"), + ); + connection_headers.insert("x-private", HeaderValue::from_static("secret")); + assert!(!should_forward_request_header( + &HeaderName::from_static("x-private"), + &connection_headers + )); + assert!(!response_headers(&connection_headers).contains_key("x-private")); +} + +#[tokio::test] +async fn prepared_gateway_request_consumes_private_client_proof() { + let request = Request::builder() + .method(Method::POST) + .uri("/v1/responses") + .header( + crate::configuration::BOOTSTRAP_CLIENT_TOKEN_HEADER, + "hmac-sha256:private-proof", + ) + .body(Body::from(r#"{"model":"gpt-test"}"#)) + .unwrap(); + + let prepared = prepare_gateway_request(&GatewayConfig::default(), request, true) + .await + .unwrap(); + + assert!(prepared.allow_environment_provider_auth); + assert!( + !prepared + .headers + .contains_key(crate::configuration::BOOTSTRAP_CLIENT_TOKEN_HEADER) + ); } #[test] @@ -102,6 +159,28 @@ fn selects_provider_routes() { assert_eq!(ProviderRoute::from_path("/unsupported"), None); } +#[test] +fn dispatch_override_routes_cover_models_and_count_tokens() { + for alias in ["openai_models", "openai.models", "/models", "/v1/models"] { + assert_eq!( + ProviderRoute::from_dispatch_override(alias), + Some(ProviderRoute::OpenAiModels), + "alias {alias}" + ); + } + for alias in [ + "anthropic_count_tokens", + "anthropic.count_tokens", + "/v1/messages/count_tokens", + ] { + assert_eq!( + ProviderRoute::from_dispatch_override(alias), + Some(ProviderRoute::AnthropicCountTokens), + "alias {alias}" + ); + } +} + #[test] fn provider_route_names_round_trip_through_alignment_routes() { for route in [ @@ -127,8 +206,8 @@ fn provider_routes_preserve_path_query_and_choose_upstream() { anthropic_base_url: "http://anthropic/".into(), metadata: None, plugin_config: None, - max_hook_payload_bytes: crate::config::DEFAULT_MAX_HOOK_PAYLOAD_BYTES, - max_passthrough_body_bytes: crate::config::DEFAULT_MAX_PASSTHROUGH_BODY_BYTES, + max_hook_payload_bytes: crate::configuration::DEFAULT_MAX_HOOK_PAYLOAD_BYTES, + max_passthrough_body_bytes: crate::configuration::DEFAULT_MAX_PASSTHROUGH_BODY_BYTES, }; assert_eq!( @@ -157,8 +236,8 @@ fn openai_upstream_url_accepts_origin_or_v1_base() { anthropic_base_url: "http://anthropic".into(), metadata: None, plugin_config: None, - max_hook_payload_bytes: crate::config::DEFAULT_MAX_HOOK_PAYLOAD_BYTES, - max_passthrough_body_bytes: crate::config::DEFAULT_MAX_PASSTHROUGH_BODY_BYTES, + max_hook_payload_bytes: crate::configuration::DEFAULT_MAX_HOOK_PAYLOAD_BYTES, + max_passthrough_body_bytes: crate::configuration::DEFAULT_MAX_PASSTHROUGH_BODY_BYTES, }; assert_eq!( @@ -334,6 +413,70 @@ fn internal_dispatch_controls_are_consumed_and_never_forwarded() { assert!(retry_aware_dispatch(&request)); } +#[test] +fn malformed_dispatch_route_discards_the_override_url() { + let original_body = Bytes::from_static(br#"{"model":"original"}"#); + let request = LlmRequest { + headers: Map::from_iter([ + ( + INTERNAL_DISPATCH_URL_HEADER.to_string(), + json!("http://127.0.0.1:9000/v1/models"), + ), + ( + INTERNAL_DISPATCH_ROUTE_HEADER.to_string(), + json!("not-a-provider-route"), + ), + ]), + content: Value::Null, + }; + + let effective = effective_dispatch_request( + &original_body, + &HeaderMap::new(), + Some(&request), + "http://default.invalid/v1/chat/completions", + ProviderRoute::OpenAiChatCompletions, + ); + + assert_eq!(effective.url, "http://default.invalid/v1/chat/completions"); + assert_eq!(effective.route, ProviderRoute::OpenAiChatCompletions); + assert!( + effective + .headers + .get(INTERNAL_DISPATCH_URL_HEADER) + .is_none() + ); + assert!( + effective + .headers + .get(INTERNAL_DISPATCH_ROUTE_HEADER) + .is_none() + ); +} + +#[test] +fn dispatch_url_without_a_route_remains_supported() { + let original_body = Bytes::from_static(br#"{"model":"original"}"#); + let request = LlmRequest { + headers: Map::from_iter([( + INTERNAL_DISPATCH_URL_HEADER.to_string(), + json!("http://127.0.0.1:9000/v1/chat/completions"), + )]), + content: Value::Null, + }; + + let effective = effective_dispatch_request( + &original_body, + &HeaderMap::new(), + Some(&request), + "http://default.invalid/v1/chat/completions", + ProviderRoute::OpenAiChatCompletions, + ); + + assert_eq!(effective.url, "http://127.0.0.1:9000/v1/chat/completions"); + assert_eq!(effective.route, ProviderRoute::OpenAiChatCompletions); +} + #[test] fn structured_upstream_failure_classification_matches_retry_policy() { let mut headers = HeaderMap::new(); @@ -434,12 +577,7 @@ async fn retry_aware_buffered_body_read_failure_stays_structured() { }); let config = GatewayConfig::default(); - let state = AppState { - config: config.clone(), - http: test_http_client(), - sessions: SessionManager::new(config), - last_activity: Arc::new(Mutex::new(std::time::Instant::now())), - }; + let state = AppState::new(config); let prepared = PreparedGatewayRequest { method: Method::POST, headers: HeaderMap::new(), @@ -449,6 +587,7 @@ async fn retry_aware_buffered_body_read_failure_stays_structured() { body_bytes: Bytes::from_static(b"{}"), request_json: json!({}), streaming: false, + allow_environment_provider_auth: false, }; let upstream_info = Arc::new(Mutex::new(None)); let upstream_error = Arc::new(Mutex::new(None)); @@ -479,8 +618,11 @@ async fn retry_aware_buffered_body_read_failure_stays_structured() { fn gateway_session_id_prefers_headers_and_has_fallbacks() { let mut headers = HeaderMap::new(); let codex_body = json!({ - "prompt_cache_key": "codex-session", - "client_metadata": { "x-codex-installation-id": "install-1" }, + "prompt_cache_key": "codex-thread", + "client_metadata": { + "x-codex-installation-id": "install-1", + "session_id": "codex-session" + }, "session_id": "body-session" }); headers.insert( @@ -618,7 +760,12 @@ fn build_llm_gateway_start_uses_alignment_identifiers_and_metadata() { "model": "gpt-test", "stream": true, "prompt_cache_key": "codex-thread", - "client_metadata": { "x-codex-installation-id": "install-1" }, + "client_metadata": { + "x-codex-installation-id": "install-1", + "x-openai-subagent": "collab_spawn", + "session_id": "codex-session", + "thread_id": "child-thread" + }, "conversation_id": "conversation-1", "generation": { "id": "generation-1" } }); @@ -631,11 +778,12 @@ fn build_llm_gateway_start_uses_alignment_identifiers_and_metadata() { body_bytes: axum::body::Bytes::new(), request_json: request_json.clone(), streaming: true, + allow_environment_provider_auth: true, }; let start = build_llm_gateway_start(&prepared); - assert_eq!(start.session_id.as_deref(), Some("codex-thread")); + assert_eq!(start.session_id.as_deref(), Some("codex-session")); assert_eq!(start.provider, "openai.responses"); assert_eq!(start.model_name.as_deref(), Some("gpt-test")); assert_eq!(start.subagent_id.as_deref(), Some("worker-1")); @@ -649,6 +797,11 @@ fn build_llm_gateway_start_uses_alignment_identifiers_and_metadata() { !start.request.headers.contains_key("authorization"), "observable headers should not leak auth secrets" ); + + let mut metadata_owned = prepared; + metadata_owned.headers.remove("x-nemo-relay-subagent-id"); + let start = build_llm_gateway_start(&metadata_owned); + assert_eq!(start.subagent_id.as_deref(), Some("child-thread")); } #[test] @@ -743,8 +896,7 @@ fn preserves_jwt_when_no_replacement_key_available() { #[test] fn injects_openai_bearer_when_inbound_has_no_auth() { - // NMF-86 mitigation: codex now sends no credentials, so the gateway must inject - // `Authorization: Bearer ${OPENAI_API_KEY}` on outbound forwards to api.openai.com. + // Foreground gateway mode retains the convenience of supplying its own provider key. let http = test_http_client(); let inbound = HeaderMap::new(); let env = |k: &str| match k { @@ -753,7 +905,7 @@ fn injects_openai_bearer_when_inbound_has_no_auth() { }; let builder = http.get("http://upstream/v1/responses"); let built = - inject_provider_auth_with_env(builder, ProviderRoute::OpenAiResponses, &inbound, env) + inject_provider_auth_with_env(builder, ProviderRoute::OpenAiResponses, &inbound, true, env) .build() .unwrap(); assert_eq!( @@ -771,10 +923,15 @@ fn injects_anthropic_x_api_key_for_anthropic_routes() { _ => None, }; let builder = http.post("http://upstream/v1/messages"); - let built = - inject_provider_auth_with_env(builder, ProviderRoute::AnthropicMessages, &inbound, env) - .build() - .unwrap(); + let built = inject_provider_auth_with_env( + builder, + ProviderRoute::AnthropicMessages, + &inbound, + true, + env, + ) + .build() + .unwrap(); assert_eq!(built.headers().get("x-api-key").unwrap(), "sk-ant-test"); // Anthropic uses `x-api-key`, not Authorization. The gateway must not duplicate the secret // into a Bearer header — that would defeat the purpose of using the provider's standard @@ -795,7 +952,7 @@ fn skips_injection_when_inbound_already_has_authorization() { let env = |_: &str| Some("sk-test-from-env".into()); let builder = http.post("http://upstream/v1/responses"); let built = - inject_provider_auth_with_env(builder, ProviderRoute::OpenAiResponses, &inbound, env) + inject_provider_auth_with_env(builder, ProviderRoute::OpenAiResponses, &inbound, true, env) .build() .unwrap(); // The builder doesn't carry inbound headers itself (forward_upstream_request adds them in a @@ -811,12 +968,31 @@ fn skips_injection_when_env_var_unset() { let env = |_: &str| None; let builder = http.post("http://upstream/v1/responses"); let built = - inject_provider_auth_with_env(builder, ProviderRoute::OpenAiResponses, &inbound, env) + inject_provider_auth_with_env(builder, ProviderRoute::OpenAiResponses, &inbound, true, env) .build() .unwrap(); assert!(built.headers().get("authorization").is_none()); } +#[test] +fn managed_sidecar_never_injects_forwarded_provider_credentials() { + let http = test_http_client(); + let inbound = HeaderMap::new(); + let env = |_: &str| Some("forwarded-secret".into()); + let builder = http.post("http://upstream/v1/responses"); + let built = inject_provider_auth_with_env( + builder, + ProviderRoute::OpenAiResponses, + &inbound, + false, + env, + ) + .build() + .unwrap(); + + assert!(built.headers().get("authorization").is_none()); +} + // --- ChatGPT backend routing tests --- #[test] @@ -936,14 +1112,21 @@ async fn passthrough_rejects_unsupported_provider_path_directly() { anthropic_base_url: "http://anthropic".into(), metadata: None, plugin_config: None, - max_hook_payload_bytes: crate::config::DEFAULT_MAX_HOOK_PAYLOAD_BYTES, - max_passthrough_body_bytes: crate::config::DEFAULT_MAX_PASSTHROUGH_BODY_BYTES, + max_hook_payload_bytes: crate::configuration::DEFAULT_MAX_HOOK_PAYLOAD_BYTES, + max_passthrough_body_bytes: crate::configuration::DEFAULT_MAX_PASSTHROUGH_BODY_BYTES, }; let state = AppState { config: config.clone(), + bootstrap_fingerprint: None, + bootstrap_challenge_key: None, + require_provider_client_token: false, http: test_http_client(), sessions: SessionManager::new(config), last_activity: std::sync::Arc::new(std::sync::Mutex::new(std::time::Instant::now())), + bootstrap_shutdown: None, + instance_id: "test-instance".into(), + bootstrap_tls: None, + local_address: None, }; let request = Request::builder() .method(Method::POST) @@ -965,14 +1148,21 @@ async fn models_rejects_non_get_requests_directly() { anthropic_base_url: "http://anthropic".into(), metadata: None, plugin_config: None, - max_hook_payload_bytes: crate::config::DEFAULT_MAX_HOOK_PAYLOAD_BYTES, - max_passthrough_body_bytes: crate::config::DEFAULT_MAX_PASSTHROUGH_BODY_BYTES, + max_hook_payload_bytes: crate::configuration::DEFAULT_MAX_HOOK_PAYLOAD_BYTES, + max_passthrough_body_bytes: crate::configuration::DEFAULT_MAX_PASSTHROUGH_BODY_BYTES, }; let state = AppState { config: config.clone(), + bootstrap_fingerprint: None, + bootstrap_challenge_key: None, + require_provider_client_token: false, http: test_http_client(), sessions: SessionManager::new(config), last_activity: std::sync::Arc::new(std::sync::Mutex::new(std::time::Instant::now())), + bootstrap_shutdown: None, + instance_id: "test-instance".into(), + bootstrap_tls: None, + local_address: None, }; let request = Request::builder() .method(Method::POST) @@ -1042,6 +1232,7 @@ async fn streaming_gateway_call_guard_finishes_when_body_is_dropped() { prep.session_id, prep.owner_subagent_id, Arc::new(Mutex::new(None)), + prep.session_finish, ); drop(body); @@ -1058,6 +1249,87 @@ async fn streaming_gateway_call_guard_finishes_when_body_is_dropped() { assert_eq!(closed, 1); } +#[test] +fn streaming_gateway_call_guard_finishes_without_a_current_runtime() { + let subscriber_name = "gateway-no-runtime-drop-test"; + let _ = nemo_relay::api::subscriber::deregister_subscriber(subscriber_name); + let captured_output = Arc::new(Mutex::new(None::)); + let captured = captured_output.clone(); + nemo_relay::api::subscriber::register_subscriber( + subscriber_name, + Arc::new(move |event| { + if event.scope_category() == Some(nemo_relay::api::event::ScopeCategory::End) + && event.name() == "codex-turn" + && event + .metadata() + .and_then(|metadata| metadata.get("session_id")) + .and_then(Value::as_str) + == Some("stream-no-runtime") + { + *captured.lock().unwrap() = event.output().cloned(); + } + }), + ) + .unwrap(); + + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + let (manager, prep) = runtime.block_on(async { + let manager = SessionManager::new(GatewayConfig::default()); + let prep = manager + .prepare_gateway_call( + &HeaderMap::new(), + LlmGatewayStart { + session_id: Some("stream-no-runtime".into()), + provider: "openai.responses".into(), + model_name: Some("gpt-test".into()), + subagent_id: None, + conversation_id: None, + generation_id: None, + request_id: None, + request: LlmRequest { + headers: Map::new(), + content: json!({ "input": "Record a final response without a runtime." }), + }, + streaming: true, + metadata: json!({}), + }, + ) + .await + .unwrap(); + (manager, prep) + }); + let final_response = json!({ "output_text": "streamed final" }); + let stream: LlmJsonStream = Box::pin(futures_util::stream::pending::< + std::result::Result, + >()); + let body = client_sse_body( + stream, + ProviderRoute::OpenAiResponses, + manager.clone(), + prep.session_id, + prep.owner_subagent_id, + Arc::new(Mutex::new(Some(final_response.clone()))), + prep.session_finish, + ); + + drop(body); + + let closed = runtime + .block_on(manager.close_idle_sessions_at( + std::time::Instant::now() + std::time::Duration::from_secs(1), + std::time::Duration::from_millis(1), + "idle_timeout", + )) + .unwrap(); + assert_eq!(closed, 1); + nemo_relay::api::subscriber::flush_subscribers().unwrap(); + assert_eq!(*captured_output.lock().unwrap(), Some(final_response)); + nemo_relay::api::subscriber::deregister_subscriber(subscriber_name).unwrap(); +} + #[tokio::test] async fn streaming_body_records_final_response_for_turn_output() { let subscriber_name = "gateway-stream-final-response-turn-output-test"; @@ -1118,6 +1390,7 @@ async fn streaming_body_records_final_response_for_turn_output() { session_id, owner_subagent_id, Arc::new(Mutex::new(Some(final_response.clone()))), + prep.session_finish, ); let _ = body.collect().await.unwrap(); diff --git a/crates/cli/tests/coverage/shared/gateway_tls_tests.rs b/crates/cli/tests/coverage/shared/gateway_tls_tests.rs new file mode 100644 index 000000000..19c67c507 --- /dev/null +++ b/crates/cli/tests/coverage/shared/gateway_tls_tests.rs @@ -0,0 +1,57 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use super::*; + +struct Environment { + _guard: std::sync::MutexGuard<'static, ()>, + previous: Vec<(&'static str, Option)>, +} + +impl Environment { + fn set(values: &[(&'static str, &std::ffi::OsStr)]) -> Self { + let guard = crate::test_support::ENV_TEST_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let previous = values + .iter() + .map(|(name, _)| (*name, std::env::var_os(name))) + .collect(); + for (name, value) in values { + unsafe { std::env::set_var(name, value) }; + } + Self { + _guard: guard, + previous, + } + } +} + +impl Drop for Environment { + fn drop(&mut self) { + for (name, value) in self.previous.drain(..) { + unsafe { + match value { + Some(value) => std::env::set_var(name, value), + None => std::env::remove_var(name), + } + } + } + } +} + +#[test] +fn per_user_tls_identity_round_trips_as_server_and_pinned_client_configs() { + let temp = tempfile::tempdir().unwrap(); + let _environment = Environment::set(&[ + ("XDG_CONFIG_HOME", temp.path().as_os_str()), + ("HOME", temp.path().as_os_str()), + ]); + + let identity = RelayTlsIdentity::load_or_create().unwrap(); + identity.server_config().unwrap(); + identity.client_config().unwrap(); + let reloaded = RelayTlsIdentity::load().unwrap(); + reloaded.server_config().unwrap(); + reloaded.client_config().unwrap(); +} diff --git a/crates/cli/tests/coverage/shared/hook_assertions.rs b/crates/cli/tests/coverage/shared/hook_assertions.rs new file mode 100644 index 000000000..10a97825e --- /dev/null +++ b/crates/cli/tests/coverage/shared/hook_assertions.rs @@ -0,0 +1,62 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use serde_json::Value; + +pub(crate) fn command_has_arguments(command: &str, expected: &[&str]) -> bool { + let arguments = crate::hooks::decode_windows_hook_command(command) + .or_else(|| shell_words::split(command).ok()); + arguments.is_some_and(|arguments| { + arguments.windows(expected.len()).any(|window| { + window + .iter() + .map(String::as_str) + .eq(expected.iter().copied()) + }) + }) +} + +pub(crate) fn value_has_command_arguments(value: &Value, expected: &[&str]) -> bool { + match value { + Value::String(_) => false, + Value::Array(values) => values + .iter() + .any(|value| value_has_command_arguments(value, expected)), + Value::Object(values) => values.iter().any(|(name, value)| { + if name == "command" { + value + .as_str() + .is_some_and(|command| command_has_arguments(command, expected)) + } else { + value_has_command_arguments(value, expected) + } + }), + Value::Null | Value::Bool(_) | Value::Number(_) => false, + } +} + +#[test] +fn command_matching_requires_complete_arguments() { + assert!(command_has_arguments( + "'/opt/NeMo Relay/nemo-relay' hook-forward codex --transparent-run", + &["hook-forward", "codex", "--transparent-run"] + )); + assert!(!command_has_arguments( + "nemo-relay hook-forward codex --transparent-run-disabled", + &["hook-forward", "codex", "--transparent-run"] + )); +} + +#[test] +fn structured_matching_ignores_non_command_metadata() { + let value = serde_json::json!({ + "description": "nemo-relay hook-forward codex --transparent-run", + "handler": { + "command": "nemo-relay hook-forward codex --transparent-run-disabled" + } + }); + assert!(!value_has_command_arguments( + &value, + &["hook-forward", "codex", "--transparent-run"] + )); +} diff --git a/crates/cli/tests/coverage/shared/install_generation_tests.rs b/crates/cli/tests/coverage/shared/install_generation_tests.rs new file mode 100644 index 000000000..db2ff48d6 --- /dev/null +++ b/crates/cli/tests/coverage/shared/install_generation_tests.rs @@ -0,0 +1,1162 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +#[cfg(unix)] +use std::fs::File; + +use tempfile::tempdir; + +use super::*; + +#[test] +fn plugin_retirement_rejects_an_external_lock_outside_its_layout() { + let dir = tempdir().unwrap(); + let marker = dir.path().join("plugin").join(GENERATION_FILE_NAME); + let expected_lock = dir.path().join("expected.lock"); + let unrelated_lock = dir.path().join("unrelated.lock"); + write_new_generation_with_token_at(&marker, &unrelated_lock).unwrap(); + + let error = match GenerationRetirement::acquire_for_plugin(&marker, &expected_lock) { + Err(error) => error, + Ok(_) => panic!("out-of-layout lock was accepted"), + }; + + assert!(error.contains("outside its plugin layout"), "{error}"); + assert!(unrelated_lock.exists()); +} + +#[cfg(unix)] +#[test] +fn plugin_retirement_accepts_an_equivalent_symlinked_external_lock_path() { + use std::os::unix::fs::symlink; + + let dir = tempdir().unwrap(); + let canonical = dir.path().join("canonical"); + let selected = dir.path().join("selected"); + std::fs::create_dir_all(&canonical).unwrap(); + symlink(&canonical, &selected).unwrap(); + let marker = selected.join("plugin").join(GENERATION_FILE_NAME); + let selected_lock = selected.join("generation.lock"); + let canonical_lock = canonical.join("generation.lock"); + write_new_generation_with_token_at(&marker, &canonical_lock).unwrap(); + + let mut retirement = GenerationRetirement::acquire_for_plugin(&marker, &selected_lock) + .unwrap() + .expect("generation exists"); + retirement.restore_after_rollback().unwrap(); +} + +#[test] +fn generation_markers_have_one_canonical_encoding() { + let lock_path = PathBuf::from("generation.lock"); + let active = GenerationMarker::active("generation-a", &lock_path); + let retired = active.retired(); + + assert_eq!(active.token(), "generation-a"); + assert_eq!(retired.token(), "generation-a"); + let active_encoded = active.encoded(); + let retired_encoded = retired.encoded(); + assert_eq!(active_encoded.lines().next(), Some("generation-a")); + assert_eq!(retired_encoded.lines().next(), Some("retired:generation-a")); + assert_eq!( + decode_lock_path( + active_encoded + .lines() + .nth(1) + .unwrap() + .strip_prefix(GENERATION_LOCK_PATH_PREFIX) + .unwrap() + ) + .unwrap(), + lock_path + ); + assert!(!active.is_retired()); + assert!(retired.is_retired()); +} + +#[test] +fn an_explicit_missing_external_lock_is_rejected_without_creating_it() { + let dir = tempdir().unwrap(); + let marker_path = dir.path().join(GENERATION_FILE_NAME); + let lock_path = dir.path().join("missing-external.lock"); + let marker = GenerationMarker::active("generation-a", &lock_path); + std::fs::write(&marker_path, marker.encoded()).unwrap(); + + let error = InstallGeneration::capture(marker_path).unwrap_err(); + + assert!( + error.contains("failed to open MCP install generation lock"), + "{error}" + ); + assert!(!lock_path.exists()); +} + +#[test] +fn an_explicit_relative_external_lock_path_is_rejected() { + let dir = tempdir().unwrap(); + let marker_path = dir.path().join(GENERATION_FILE_NAME); + let marker = GenerationMarker::active("generation-a", "relative-generation.lock"); + std::fs::write(&marker_path, marker.encoded()).unwrap(); + + let error = InstallGeneration::capture(marker_path).unwrap_err(); + + assert!(error.contains("non-absolute external lock path"), "{error}"); +} + +#[test] +fn an_explicit_empty_external_lock_is_rejected_without_modifying_it() { + let dir = tempdir().unwrap(); + let marker_path = dir.path().join(GENERATION_FILE_NAME); + let lock_path = dir.path().join("empty-external.lock"); + std::fs::write(&lock_path, []).unwrap(); + let marker = GenerationMarker::active("generation-a", &lock_path); + std::fs::write(&marker_path, marker.encoded()).unwrap(); + + let error = InstallGeneration::capture(marker_path.clone()).unwrap_err(); + assert!(error.contains("generation lock"), "{error}"); + assert!(error.contains("is empty"), "{error}"); + assert_eq!(std::fs::read(&lock_path).unwrap(), b""); + + let error = GenerationRetirement::acquire(&marker_path) + .err() + .expect("empty external lock was accepted"); + assert!(error.contains("generation lock"), "{error}"); + assert!(error.contains("is empty"), "{error}"); + assert_eq!(std::fs::read(lock_path).unwrap(), b""); +} + +#[test] +fn retirement_without_invalidation_only_releases_the_lock() { + let dir = tempdir().unwrap(); + let path = dir.path().join(GENERATION_FILE_NAME); + write_new_generation(&path).unwrap(); + let before = std::fs::read(&path).unwrap(); + let mut retirement = GenerationRetirement::acquire(&path).unwrap().unwrap(); + + retirement.restore_after_rollback().unwrap(); + + assert!(retirement.lock.is_none()); + assert!(!retirement.changed); + assert_eq!(std::fs::read(&path).unwrap(), before); +} + +#[test] +fn active_generation_guard_fences_retirement_until_startup_finishes() { + let dir = tempdir().unwrap(); + let path = dir.path().join(GENERATION_FILE_NAME); + write_new_generation(&path).unwrap(); + let generation = InstallGeneration::capture(path.clone()).unwrap(); + let guard = generation.guard_current().unwrap(); + + let error = match GenerationRetirement::acquire_with_timeout(&path, Duration::from_millis(20)) { + Err(error) => error, + Ok(_) => panic!("retirement must wait for the active startup guard"), + }; + assert!(error.contains("timed out waiting"), "{error}"); + + drop(guard); + assert!( + GenerationRetirement::acquire_with_timeout(&path, Duration::from_secs(1)) + .unwrap() + .is_some() + ); +} + +#[test] +fn guarded_capture_fences_retirement_without_a_reacquisition_gap() { + let dir = tempdir().unwrap(); + let path = dir.path().join(GENERATION_FILE_NAME); + write_new_generation(&path).unwrap(); + let (_generation, guard) = InstallGeneration::capture_guarded(path.clone()).unwrap(); + + let error = GenerationRetirement::acquire_with_timeout(&path, Duration::from_millis(20)) + .err() + .expect("retirement entered between generation capture and its guard"); + assert!(error.contains("timed out waiting"), "{error}"); + + drop(guard); + assert!( + GenerationRetirement::acquire_with_timeout(&path, Duration::from_secs(1)) + .unwrap() + .is_some() + ); +} + +#[test] +fn expected_token_rejects_a_stale_launcher_after_same_path_rotation() { + let dir = tempdir().unwrap(); + let path = dir.path().join(GENERATION_FILE_NAME); + let stale_token = write_new_generation_with_token(&path).unwrap(); + let current_token = write_new_generation_with_token(&path).unwrap(); + + let error = InstallGeneration::capture_guarded_expected(path.clone(), &stale_token) + .err() + .expect("stale launcher adopted the replacement generation"); + + assert!(error.contains("has been retired"), "{error}"); + let (generation, guard) = + InstallGeneration::capture_guarded_expected(path.clone(), ¤t_token).unwrap(); + assert_eq!(generation.token(), current_token); + assert_eq!( + InstallGeneration::capture(path).unwrap().token(), + current_token + ); + drop(guard); +} + +#[test] +fn generation_environment_requires_and_verifies_the_complete_identity_pair() { + struct EnvironmentRestore { + file: Option, + token: Option, + } + impl Drop for EnvironmentRestore { + fn drop(&mut self) { + // SAFETY: This test holds the repository-wide environment mutex. + unsafe { + match self.file.take() { + Some(value) => std::env::set_var(GENERATION_FILE_ENV, value), + None => std::env::remove_var(GENERATION_FILE_ENV), + } + match self.token.take() { + Some(value) => std::env::set_var(GENERATION_TOKEN_ENV, value), + None => std::env::remove_var(GENERATION_TOKEN_ENV), + } + } + } + } + + let _environment = crate::test_support::ENV_TEST_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let _restore = EnvironmentRestore { + file: std::env::var_os(GENERATION_FILE_ENV), + token: std::env::var_os(GENERATION_TOKEN_ENV), + }; + // SAFETY: This test holds the repository-wide environment mutex. + unsafe { + std::env::remove_var(GENERATION_FILE_ENV); + std::env::remove_var(GENERATION_TOKEN_ENV); + } + assert!( + InstallGeneration::capture_guarded_from_env() + .unwrap() + .is_none() + ); + + let dir = tempdir().unwrap(); + let path = dir.path().join(GENERATION_FILE_NAME); + let token = write_new_generation_with_token(&path).unwrap(); + // SAFETY: This test holds the repository-wide environment mutex. + unsafe { std::env::set_var(GENERATION_FILE_ENV, &path) }; + let error = InstallGeneration::capture_guarded_from_env() + .err() + .expect("generation path without identity was accepted"); + assert!(error.contains(GENERATION_TOKEN_ENV), "{error}"); + + // SAFETY: This test holds the repository-wide environment mutex. + unsafe { + std::env::remove_var(GENERATION_FILE_ENV); + std::env::set_var(GENERATION_TOKEN_ENV, &token); + } + let error = InstallGeneration::capture_guarded_from_env() + .err() + .expect("generation identity without path was accepted"); + assert!(error.contains(GENERATION_FILE_ENV), "{error}"); + + // SAFETY: This test holds the repository-wide environment mutex. + unsafe { std::env::set_var(GENERATION_FILE_ENV, &path) }; + let (generation, guard) = InstallGeneration::capture_guarded_from_env() + .unwrap() + .unwrap(); + assert_eq!(generation.token(), token); + drop(guard); + + // SAFETY: This test holds the repository-wide environment mutex. + unsafe { std::env::set_var(GENERATION_TOKEN_ENV, "stale-generation") }; + let error = InstallGeneration::capture_guarded_from_env() + .err() + .expect("stale generation identity was accepted"); + assert!(error.contains("has been retired"), "{error}"); +} + +#[test] +fn guarded_capture_rejects_a_marker_and_lock_replaced_after_open() { + let dir = tempdir().unwrap(); + let old_path = dir.path().join("old").join(GENERATION_FILE_NAME); + let path = dir.path().join("visible").join(GENERATION_FILE_NAME); + write_new_generation(&old_path).unwrap(); + write_new_generation(&path).unwrap(); + std::fs::write(&old_path, "same-marker\n").unwrap(); + std::fs::write(&path, "same-marker\n").unwrap(); + let old_marker = open_generation(&old_path).unwrap(); + let old_lock = open_generation_lock(&old_path).unwrap(); + + // Model a force install promoting generation B after capture opened generation A's files. + // Both markers intentionally match, so the visible lock-file identity is what must reject the + // stale handle pair. Distinct paths model the post-promotion view without relying on replacing + // an open file, which Windows does not permit through MoveFileEx. + + let observed = read_generation_marker(&old_marker, &old_path).unwrap(); + let error = InstallGeneration::capture_guarded_open_files_with_lock( + path.clone(), + old_marker, + old_lock, + observed, + ) + .err() + .expect("capture adopted a new marker through the old generation lock"); + + assert!(error.contains("has been retired"), "{error}"); + assert!(error.contains(&path.display().to_string()), "{error}"); +} + +#[test] +fn rollback_does_not_publish_the_old_token_through_a_replacement_lock() { + let dir = tempdir().unwrap(); + let plugin = dir.path().join("plugin"); + let backup = dir.path().join("plugin-backup"); + let path = plugin.join(GENERATION_FILE_NAME); + let lock_path = dir.path().join("generation-transaction.lock"); + write_new_generation_with_token_at(&path, &lock_path).unwrap(); + let mut retirement = GenerationRetirement::acquire(&path).unwrap().unwrap(); + retirement.invalidate_for_replacement().unwrap(); + + std::fs::rename(&plugin, &backup).unwrap(); + let replacement_token = write_staged_generation_with_token(&path, &lock_path).unwrap(); + let replacement_marker = std::fs::read(&path).unwrap(); + + let error = retirement.restore_after_rollback().unwrap_err(); + + assert!(error.contains("lock identity changed"), "{error}"); + assert_eq!(std::fs::read(&path).unwrap(), replacement_marker); + drop(retirement); + assert_eq!( + InstallGeneration::capture(path).unwrap().token(), + replacement_token + ); +} + +#[test] +fn staged_generation_lock_remains_held_across_marketplace_promotion() { + let dir = tempdir().unwrap(); + let staged_plugin = dir.path().join("staged").join("plugin"); + let live_plugin = dir.path().join("live").join("plugin"); + let staged_marker = staged_plugin.join(GENERATION_FILE_NAME); + let live_marker = live_plugin.join(GENERATION_FILE_NAME); + let lock_path = dir.path().join("replacement-generation.lock"); + write_new_generation_with_token_at(&staged_marker, &lock_path).unwrap(); + let mut retirement = GenerationRetirement::acquire(&staged_marker) + .unwrap() + .unwrap(); + + std::fs::create_dir_all(live_plugin.parent().unwrap()).unwrap(); + std::fs::rename(&staged_plugin, &live_plugin).unwrap(); + retirement.retarget_promoted_marker(&live_marker).unwrap(); + + let error = GenerationRetirement::acquire_with_timeout(&live_marker, Duration::from_millis(20)) + .err() + .expect("promoted generation escaped its staged transaction lock"); + assert!(error.contains("timed out waiting"), "{error}"); + + drop(retirement); + assert!( + GenerationRetirement::acquire_with_timeout(&live_marker, Duration::from_secs(1)) + .unwrap() + .is_some() + ); +} + +#[test] +fn legacy_sibling_lock_can_be_released_for_tree_move_and_reacquired_for_rollback() { + let dir = tempdir().unwrap(); + let plugin = dir.path().join("plugin"); + let backup = dir.path().join("plugin-backup"); + let marker_path = plugin.join(GENERATION_FILE_NAME); + write_legacy_generation(&marker_path, "generation-a").unwrap(); + let mut retirement = GenerationRetirement::acquire(&marker_path) + .unwrap() + .unwrap(); + retirement.invalidate_for_replacement().unwrap(); + retirement.release_legacy_lock_for_tree_mutation().unwrap(); + + std::fs::rename(&plugin, &backup).unwrap(); + std::fs::rename(&backup, &plugin).unwrap(); + retirement.restore_after_rollback().unwrap(); + + assert_eq!( + InstallGeneration::capture(marker_path).unwrap().token(), + "generation-a" + ); +} + +#[test] +fn relative_legacy_marker_reencodes_an_absolute_lock_for_rollback() { + let _cwd = crate::test_support::CwdTestScope::locked(); + let current_dir = std::env::current_dir().unwrap(); + let dir = tempfile::Builder::new() + .prefix(".relay-relative-generation-") + .tempdir_in(¤t_dir) + .unwrap(); + let relative_root = dir.path().strip_prefix(¤t_dir).unwrap(); + let plugin = relative_root.join("plugin"); + let backup = relative_root.join("plugin-backup"); + let marker_path = plugin.join(GENERATION_FILE_NAME); + assert!(!marker_path.is_absolute()); + write_legacy_generation(&marker_path, "generation-a").unwrap(); + let mut retirement = GenerationRetirement::acquire(&marker_path) + .unwrap() + .unwrap(); + + retirement.invalidate_for_replacement().unwrap(); + let retired = read_generation_marker_path(&marker_path).unwrap(); + assert!(retired.is_retired()); + assert!(retired.lock_path().is_absolute()); + retirement.release_legacy_lock_for_tree_mutation().unwrap(); + std::fs::rename(&plugin, &backup).unwrap(); + std::fs::rename(&backup, &plugin).unwrap(); + retirement.restore_after_rollback().unwrap(); + + assert_eq!( + InstallGeneration::capture(marker_path).unwrap().token(), + "generation-a" + ); +} + +#[test] +fn generation_lock_identity_is_independent_of_path_spelling() { + let dir = tempdir().unwrap(); + let alias_dir = dir.path().join("alias"); + std::fs::create_dir(&alias_dir).unwrap(); + let marker_path = dir.path().join(GENERATION_FILE_NAME); + let lock_path = dir.path().join("generation-transaction.lock"); + write_new_generation_with_token_at(&marker_path, &lock_path).unwrap(); + let retirement = GenerationRetirement::acquire(&marker_path) + .unwrap() + .unwrap(); + let aliased_lock_path = alias_dir.join("..").join("generation-transaction.lock"); + + assert_ne!(retirement.lock_path(), aliased_lock_path); + assert!(retirement.uses_lock_path(&aliased_lock_path).unwrap()); +} + +#[test] +fn installer_reads_the_promoted_token_through_its_existing_transaction() { + let dir = tempdir().unwrap(); + let plugin = dir.path().join("plugin"); + let backup = dir.path().join("plugin-backup"); + let marker_path = plugin.join(GENERATION_FILE_NAME); + let lock_path = dir.path().join("generation-transaction.lock"); + write_new_generation_with_token_at(&marker_path, &lock_path).unwrap(); + let mut retirement = GenerationRetirement::acquire(&marker_path) + .unwrap() + .unwrap(); + retirement.invalidate_for_replacement().unwrap(); + std::fs::rename(&plugin, &backup).unwrap(); + let replacement = write_staged_generation_with_token(&marker_path, &lock_path).unwrap(); + + assert_eq!(retirement.active_visible_token().unwrap(), replacement); + retirement.commit_replacement(); +} + +#[cfg(windows)] +#[test] +fn windows_visible_lock_validation_uses_the_owning_locked_handle() { + let dir = tempdir().unwrap(); + let path = dir.path().join(GENERATION_FILE_NAME); + write_new_generation(&path).unwrap(); + let lock_path = generation_lock_path(&path); + let file = open_generation_lock(&path).unwrap(); + lock_exclusive_with_timeout(&file, &path, Duration::from_secs(1)).unwrap(); + let identity = ensure_generation_lock_identity_locked(&file, &lock_path).unwrap(); + + assert!(visible_generation_lock_matches(&file, &lock_path, &identity).unwrap()); + + unlock_file(&file).unwrap(); +} + +#[cfg(windows)] +#[test] +fn windows_retirement_lock_survives_tree_rename_restore_and_removal() { + let dir = tempdir().unwrap(); + let plugin = dir.path().join("plugin"); + let backup = dir.path().join("plugin-backup"); + let path = plugin.join(GENERATION_FILE_NAME); + let lock_path = dir.path().join("generation-transaction.lock"); + write_new_generation_with_token_at(&path, &lock_path).unwrap(); + let original = std::fs::read(&path).unwrap(); + let mut retirement = GenerationRetirement::acquire(&path).unwrap().unwrap(); + retirement.invalidate_for_replacement().unwrap(); + + std::fs::rename(&plugin, &backup).unwrap(); + std::fs::rename(&backup, &plugin).unwrap(); + retirement.restore_after_rollback().unwrap(); + assert_eq!(std::fs::read(&path).unwrap(), original); + + let mut retirement = GenerationRetirement::acquire(&path).unwrap().unwrap(); + retirement.invalidate_for_replacement().unwrap(); + retirement.commit_replacement(); + std::fs::remove_dir_all(&plugin).unwrap(); + assert!(!plugin.exists()); +} + +#[test] +fn guarded_capture_rejects_a_rolled_back_marker_with_the_replacement_lock() { + let dir = tempdir().unwrap(); + let path = dir.path().join(GENERATION_FILE_NAME); + write_new_generation(&path).unwrap(); + let old_marker = open_generation(&path).unwrap(); + + // Model capture opening marker A, then opening generation B's lock while B is promoted. The + // installer rolls the visible tree back to A before capture obtains the shared lock. Marker + // equality alone would adopt A while retaining B's unlinked lock. + let replacement_path = dir.path().join("replacement-generation"); + write_new_generation(&replacement_path).unwrap(); + let replacement_lock = open_generation_lock(&replacement_path).unwrap(); + + let observed = read_generation_marker(&old_marker, &path).unwrap(); + let error = InstallGeneration::capture_guarded_open_files_with_lock( + path.clone(), + old_marker, + replacement_lock, + observed, + ) + .err() + .expect("capture adopted the rolled-back marker through the replacement lock"); + + assert!(error.contains("has been retired"), "{error}"); + assert!(error.contains(&path.display().to_string()), "{error}"); +} + +#[test] +fn rollback_can_restore_with_the_original_lock_still_held() { + let dir = tempdir().unwrap(); + let path = dir.path().join(GENERATION_FILE_NAME); + std::fs::write(&path, "retired:generation-a\n").unwrap(); + let lock = open_generation_lock(&path).unwrap(); + lock_exclusive_with_timeout(&lock, &path, Duration::from_secs(1)).unwrap(); + let lock_id = + ensure_generation_lock_identity_locked(&lock, &generation_lock_path(&path)).unwrap(); + let mut retirement = GenerationRetirement { + lock: Some(lock), + lock_id, + path: path.clone(), + original: GenerationMarker::active("generation-a", generation_lock_path(&path)), + changed: true, + committed: false, + lock_released_for_tree_mutation: false, + }; + + retirement.restore_after_rollback().unwrap(); + + assert_eq!( + read_generation_marker_path(&path).unwrap(), + GenerationMarker::active("generation-a", generation_lock_path(&path)) + ); + assert!(!retirement.changed); +} + +#[test] +fn rollback_restores_the_visible_path_after_atomic_marker_replacement() { + let dir = tempdir().unwrap(); + let path = dir.path().join(GENERATION_FILE_NAME); + write_new_generation(&path).unwrap(); + let original_bytes = std::fs::read(&path).unwrap(); + let original = InstallGeneration::capture(path.clone()).unwrap(); + let mut retirement = GenerationRetirement::acquire(&path).unwrap().unwrap(); + retirement.invalidate_for_replacement().unwrap(); + + atomic_write(&path, retirement.original.retired().encoded().as_bytes()).unwrap(); + + retirement.restore_after_rollback().unwrap(); + assert_eq!(std::fs::read(&path).unwrap(), original_bytes); + + // A rollback must not replace the inode that old clients use for fencing. Otherwise a later + // retirement can lock the visible generation while an old client remains guarded through an + // unlinked inode (an ABA race). + let guard = original.guard_current().unwrap(); + let error = GenerationRetirement::acquire_with_timeout(&path, Duration::from_millis(20)) + .err() + .expect("retirement bypassed the pre-rollback generation guard"); + assert!(error.contains("timed out waiting"), "{error}"); + + drop(guard); + assert!( + GenerationRetirement::acquire_with_timeout(&path, Duration::from_secs(1)) + .unwrap() + .is_some() + ); +} + +#[test] +fn invalidation_requires_a_live_exclusive_lock() { + let dir = tempdir().unwrap(); + let path = dir.path().join(GENERATION_FILE_NAME); + let mut retirement = GenerationRetirement { + lock: None, + lock_id: uuid::Uuid::nil().to_string(), + path: path.clone(), + original: GenerationMarker::active("generation-a", generation_lock_path(&path)), + changed: false, + committed: false, + lock_released_for_tree_mutation: false, + }; + + let error = retirement.invalidate_for_replacement().unwrap_err(); + + assert!(error.contains("is not locked"), "{error}"); + assert!(error.contains(&path.display().to_string()), "{error}"); +} + +#[test] +fn failed_invalidation_restores_a_marker_changed_by_partial_io() { + let dir = tempdir().unwrap(); + let path = dir.path().join(GENERATION_FILE_NAME); + write_new_generation(&path).unwrap(); + let original = std::fs::read(&path).unwrap(); + let mut retirement = GenerationRetirement::acquire(&path).unwrap().unwrap(); + + let error = retirement + .invalidate_with(|path, _retired| { + std::fs::write(path, "").unwrap(); + Err("injected generation write failure after truncation".into()) + }) + .unwrap_err(); + + assert!( + error.contains("injected generation write failure"), + "{error}" + ); + assert_eq!(std::fs::read(&path).unwrap(), original); + assert!(!retirement.changed); +} + +#[test] +fn an_already_retired_generation_keeps_its_replacement_transaction_lock() { + let dir = tempdir().unwrap(); + let marker_path = dir.path().join(GENERATION_FILE_NAME); + let lock_path = dir.path().join("generation-transaction.lock"); + write_new_generation_with_token_at(&marker_path, &lock_path).unwrap(); + let mut first = GenerationRetirement::acquire(&marker_path) + .unwrap() + .unwrap(); + first.invalidate_for_replacement().unwrap(); + first.commit_replacement(); + drop(first); + + let mut retry = GenerationRetirement::acquire(&marker_path) + .unwrap() + .unwrap(); + retry.invalidate_for_replacement().unwrap(); + let error = GenerationRetirement::acquire_with_timeout(&marker_path, Duration::from_millis(20)) + .err() + .expect("already-retired retry released its transaction lock"); + assert!(error.contains("timed out waiting"), "{error}"); + + let replacement = write_staged_generation_with_token(&marker_path, &lock_path).unwrap(); + assert_eq!(retry.active_visible_token().unwrap(), replacement); +} + +#[test] +fn dropping_an_uncommitted_retirement_restores_the_original_marker() { + let dir = tempdir().unwrap(); + let path = dir.path().join(GENERATION_FILE_NAME); + write_new_generation(&path).unwrap(); + let original = std::fs::read(&path).unwrap(); + + { + let mut retirement = GenerationRetirement::acquire(&path).unwrap().unwrap(); + retirement.invalidate_for_replacement().unwrap(); + } + + assert_eq!(std::fs::read(path).unwrap(), original); +} + +#[test] +fn rollback_requires_the_original_transaction_lock() { + let dir = tempdir().unwrap(); + let path = dir.path().join("missing-generation"); + let mut retirement = GenerationRetirement { + lock: None, + lock_id: uuid::Uuid::nil().to_string(), + path: path.clone(), + original: GenerationMarker::active("generation-a", generation_lock_path(&path)), + changed: true, + committed: false, + lock_released_for_tree_mutation: false, + }; + + let error = retirement.restore_after_rollback().unwrap_err(); + + assert!(error.contains("has no transaction lock"), "{error}"); + assert!(error.contains(&path.display().to_string()), "{error}"); +} + +#[test] +fn marker_replacement_preserves_the_operation_in_io_errors() { + let dir = tempdir().unwrap(); + let parent = dir.path().join("not-a-directory"); + std::fs::write(&parent, "file").unwrap(); + let path = parent.join(GENERATION_FILE_NAME); + + let error = replace_generation_marker( + &path, + &GenerationMarker::active("generation-a", generation_lock_path(&path)).retired(), + "invalidate", + ) + .unwrap_err(); + + assert!(error.contains("failed to invalidate"), "{error}"); + assert!(error.contains(&path.display().to_string()), "{error}"); +} + +#[test] +fn malformed_retirement_requires_a_token() { + let dir = tempdir().unwrap(); + let path = dir.path().join(GENERATION_FILE_NAME); + std::fs::write(&path, "retired:\n").unwrap(); + + let error = match GenerationRetirement::acquire(&path) { + Err(error) => error, + Ok(_) => panic!("a retired marker without a token must be rejected"), + }; + + assert!(error.contains("retired marker without a token"), "{error}"); +} + +#[test] +fn generation_creation_reports_an_invalid_parent() { + let dir = tempdir().unwrap(); + let parent = dir.path().join("not-a-directory"); + std::fs::write(&parent, "file").unwrap(); + + let error = write_new_generation(&parent.join(GENERATION_FILE_NAME)).unwrap_err(); + + assert!(error.contains("failed to create"), "{error}"); +} + +#[test] +fn generation_creation_reports_an_unwritable_target_shape() { + let dir = tempdir().unwrap(); + + let error = write_new_generation(dir.path()).unwrap_err(); + + assert!(error.contains("failed to replace"), "{error}"); +} + +#[test] +fn generation_creation_provisions_a_stable_sibling_lock() { + let dir = tempdir().unwrap(); + let path = dir.path().join(GENERATION_FILE_NAME); + + write_new_generation(&path).unwrap(); + + assert!(generation_lock_path(&path).is_file()); + uuid::Uuid::parse_str(&read_generation_lock_identity_path(&path).unwrap()).unwrap(); +} + +#[test] +fn an_empty_generation_lock_is_initialized_before_capture() { + let dir = tempdir().unwrap(); + let path = dir.path().join(GENERATION_FILE_NAME); + write_new_generation(&path).unwrap(); + std::fs::write(generation_lock_path(&path), "").unwrap(); + + InstallGeneration::capture(path.clone()).unwrap(); + + uuid::Uuid::parse_str(&read_generation_lock_identity_path(&path).unwrap()).unwrap(); +} + +#[test] +fn a_malformed_generation_lock_identity_is_rejected() { + let dir = tempdir().unwrap(); + let path = dir.path().join(GENERATION_FILE_NAME); + write_new_generation(&path).unwrap(); + std::fs::write(generation_lock_path(&path), "not-a-uuid\n").unwrap(); + + let capture_error = InstallGeneration::capture(path.clone()).unwrap_err(); + assert!( + capture_error.contains("invalid identity"), + "{capture_error}" + ); + + let retirement_error = GenerationRetirement::acquire(&path) + .err() + .expect("retirement accepted a malformed lock identity"); + assert!( + retirement_error.contains("invalid identity"), + "{retirement_error}" + ); +} + +#[test] +fn generation_marker_parser_rejects_every_malformed_record_shape() { + let dir = tempdir().unwrap(); + let path = dir.path().join(GENERATION_FILE_NAME); + let absolute_lock = dir.path().join("generation.lock"); + let encoded_lock = encode_lock_path(&absolute_lock); + let cases = [ + (String::new(), "is empty"), + ( + format!("{}\n", "x".repeat(MAX_GENERATION_TOKEN_BYTES + 1)), + "token in", + ), + ( + format!("generation-a\nwrong-prefix:{encoded_lock}\n"), + "invalid lock-path record", + ), + ( + format!("generation-a\n{GENERATION_LOCK_PATH_PREFIX}{encoded_lock}\nunexpected\n"), + "unexpected trailing records", + ), + ( + format!("generation-a\n{GENERATION_LOCK_PATH_PREFIX}not-base64!\n"), + "invalid lock path", + ), + ( + format!("generation-a\n{GENERATION_LOCK_PATH_PREFIX}\n"), + "lock path is empty", + ), + ( + format!("{}\n", "x".repeat(MAX_GENERATION_MARKER_BYTES + 1)), + "byte limit", + ), + ]; + + for (contents, expected) in cases { + std::fs::write(&path, contents).unwrap(); + let error = read_generation_marker_path(&path).unwrap_err(); + assert!( + error.contains(expected), + "expected {expected:?} in {error:?}" + ); + } + let oversized = std::fs::read_to_string(&path).unwrap(); + assert_eq!(oversized.len(), MAX_GENERATION_MARKER_BYTES + 2); + assert!( + read_generation_marker_path(&path) + .unwrap_err() + .contains("byte limit") + ); +} + +#[test] +fn generation_lock_identity_reader_rejects_oversized_records() { + let dir = tempdir().unwrap(); + let marker = dir.path().join(GENERATION_FILE_NAME); + write_new_generation(&marker).unwrap(); + let lock = generation_lock_path(&marker); + std::fs::write(&lock, "x".repeat(MAX_GENERATION_LOCK_ID_BYTES + 1)).unwrap(); + + let capture_error = InstallGeneration::capture(marker.clone()).unwrap_err(); + assert!(capture_error.contains("byte limit"), "{capture_error}"); + let retirement_error = GenerationRetirement::acquire(&marker) + .err() + .expect("retirement accepted an oversized lock identity"); + assert!( + retirement_error.contains("byte limit"), + "{retirement_error}" + ); +} + +#[cfg(unix)] +#[test] +fn generation_environment_rejects_non_unicode_expected_identity() { + use std::os::unix::ffi::OsStringExt; + + struct Restore { + path: Option, + token: Option, + } + impl Drop for Restore { + fn drop(&mut self) { + // SAFETY: The test holds the process-wide environment lock. + unsafe { + match self.path.take() { + Some(value) => std::env::set_var(GENERATION_FILE_ENV, value), + None => std::env::remove_var(GENERATION_FILE_ENV), + } + match self.token.take() { + Some(value) => std::env::set_var(GENERATION_TOKEN_ENV, value), + None => std::env::remove_var(GENERATION_TOKEN_ENV), + } + } + } + } + + let _environment = crate::test_support::ENV_TEST_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let _restore = Restore { + path: std::env::var_os(GENERATION_FILE_ENV), + token: std::env::var_os(GENERATION_TOKEN_ENV), + }; + unsafe { + std::env::set_var(GENERATION_FILE_ENV, "/unused/generation"); + std::env::set_var( + GENERATION_TOKEN_ENV, + std::ffi::OsString::from_vec(vec![0xff]), + ); + } + + let error = InstallGeneration::capture_guarded_from_env() + .err() + .expect("non-Unicode expected identity was accepted"); + assert!(error.contains("not valid Unicode"), "{error}"); +} + +#[test] +fn retirement_state_machine_rejects_invalid_transitions_without_losing_its_lock() { + let dir = tempdir().unwrap(); + let legacy_marker = dir.path().join("legacy").join(GENERATION_FILE_NAME); + write_legacy_generation(&legacy_marker, "generation-a").unwrap(); + let mut legacy = GenerationRetirement::acquire(&legacy_marker) + .unwrap() + .unwrap(); + + let active_release_error = legacy.release_legacy_lock_for_tree_mutation().unwrap_err(); + assert!( + active_release_error.contains("cannot release active"), + "{active_release_error}" + ); + assert!( + !legacy + .uses_lock_path(&dir.path().join("missing.lock")) + .unwrap() + ); + let invalid_shape = dir.path().join("not-a-directory"); + std::fs::write(&invalid_shape, b"file").unwrap(); + let inspection_error = legacy + .uses_lock_path(&invalid_shape.join("nested").join("lock")) + .unwrap_err(); + assert!( + inspection_error.contains("failed to inspect"), + "{inspection_error}" + ); + + legacy.invalidate_for_replacement().unwrap(); + assert!( + legacy + .active_visible_token() + .unwrap_err() + .contains("retired") + ); + legacy.release_legacy_lock_for_tree_mutation().unwrap(); + legacy.release_legacy_lock_for_tree_mutation().unwrap(); + legacy.restore_after_rollback().unwrap(); + + let external_marker = dir.path().join("external").join(GENERATION_FILE_NAME); + let external_lock = dir.path().join("external-generation.lock"); + write_new_generation_with_token_at(&external_marker, &external_lock).unwrap(); + let mut external = GenerationRetirement::acquire(&external_marker) + .unwrap() + .unwrap(); + external.release_legacy_lock_for_tree_mutation().unwrap(); + let replacement_lock = dir.path().join("replacement.lock"); + write_new_generation_with_token_at(&external_marker, &replacement_lock).unwrap(); + assert!( + external + .active_visible_token() + .unwrap_err() + .contains("retired") + ); + let marker_error = external + .retarget_promoted_marker(&external_marker) + .unwrap_err(); + assert!(marker_error.contains("marker changed"), "{marker_error}"); + external.commit_replacement(); + let committed_error = external + .retarget_promoted_marker(&external_marker) + .unwrap_err(); + assert!( + committed_error.contains("cannot retarget mutated"), + "{committed_error}" + ); +} + +#[test] +fn captured_generation_rejects_visible_marker_rotation_on_every_verification_path() { + let dir = tempdir().unwrap(); + let marker = dir.path().join(GENERATION_FILE_NAME); + let lock = dir.path().join("generation.lock"); + write_new_generation_with_token_at(&marker, &lock).unwrap(); + let generation = InstallGeneration::capture(marker.clone()).unwrap(); + + write_staged_generation_with_token(&marker, &lock).unwrap(); + + assert!( + generation + .try_verify_current() + .unwrap_err() + .contains("retired") + ); + assert!( + generation + .guard_current() + .err() + .expect("rotated marker was guarded") + .contains("retired") + ); +} + +#[cfg(unix)] +#[test] +fn promoted_generation_rejects_replaced_external_lock_inode() { + let dir = tempdir().unwrap(); + let marker = dir.path().join(GENERATION_FILE_NAME); + let lock = dir.path().join("generation.lock"); + write_new_generation_with_token_at(&marker, &lock).unwrap(); + let mut retirement = GenerationRetirement::acquire(&marker).unwrap().unwrap(); + + std::fs::remove_file(&lock).unwrap(); + std::fs::write(&lock, format!("{}\n", uuid::Uuid::now_v7())).unwrap(); + + let error = retirement.retarget_promoted_marker(&marker).unwrap_err(); + assert!(error.contains("lock identity changed"), "{error}"); +} + +#[test] +fn retirement_reports_marker_inspection_errors_and_repeated_invalidation_is_idempotent() { + let dir = tempdir().unwrap(); + let invalid_parent = dir.path().join("not-a-directory"); + std::fs::write(&invalid_parent, b"file").unwrap(); + let inspection_error = + GenerationRetirement::acquire(&invalid_parent.join("nested").join(GENERATION_FILE_NAME)) + .err() + .expect("invalid marker parent was accepted"); + assert!( + inspection_error.contains("failed to inspect"), + "{inspection_error}" + ); + + let marker = dir.path().join("valid").join(GENERATION_FILE_NAME); + write_new_generation(&marker).unwrap(); + let mut retirement = GenerationRetirement::acquire(&marker).unwrap().unwrap(); + retirement.invalidate_for_replacement().unwrap(); + let retired = std::fs::read(&marker).unwrap(); + retirement.invalidate_for_replacement().unwrap(); + assert_eq!(std::fs::read(&marker).unwrap(), retired); + retirement.restore_after_rollback().unwrap(); +} + +#[test] +fn failed_invalidation_aggregates_a_failed_marker_restore() { + let dir = tempdir().unwrap(); + let plugin = dir.path().join("plugin"); + let marker = plugin.join(GENERATION_FILE_NAME); + let lock = dir.path().join("external.lock"); + write_new_generation_with_token_at(&marker, &lock).unwrap(); + let mut retirement = GenerationRetirement::acquire(&marker).unwrap().unwrap(); + + let error = retirement + .invalidate_with(|path, _retired| { + crate::filesystem::fail_next_atomic_write(path); + Err("injected invalidation failure".into()) + }) + .unwrap_err(); + + assert!(error.contains("injected invalidation failure"), "{error}"); + assert!(error.contains("additionally"), "{error}"); + retirement.commit_replacement(); +} + +#[test] +fn generation_writers_report_each_invalid_parent_lock_and_identity_shape() { + let dir = tempdir().unwrap(); + let invalid_parent = dir.path().join("not-a-directory"); + std::fs::write(&invalid_parent, b"file").unwrap(); + let marker_under_file = invalid_parent.join(GENERATION_FILE_NAME); + let external_lock = dir.path().join("external.lock"); + + let legacy_error = write_legacy_generation(&marker_under_file, "generation-a").unwrap_err(); + assert!(legacy_error.contains("failed to create"), "{legacy_error}"); + let marker_parent_error = + write_new_generation_with_token_at(&marker_under_file, &external_lock).unwrap_err(); + assert!( + marker_parent_error.contains("failed to create"), + "{marker_parent_error}" + ); + let lock_parent_error = write_new_generation_with_token_at( + &dir.path().join("marker"), + &invalid_parent.join("lock"), + ) + .unwrap_err(); + assert!( + lock_parent_error.contains("failed to create"), + "{lock_parent_error}" + ); + let staged_error = + write_staged_generation_with_token(&marker_under_file, &external_lock).unwrap_err(); + assert!(staged_error.contains("failed to create"), "{staged_error}"); + + let malformed_lock = dir.path().join("malformed.lock"); + std::fs::write(&malformed_lock, b"not-a-uuid\n").unwrap(); + let malformed_error = + write_new_generation_with_token_at(&dir.path().join("malformed-marker"), &malformed_lock) + .unwrap_err(); + assert!( + malformed_error.contains("invalid identity"), + "{malformed_error}" + ); + + let directory_lock_error = + write_new_generation_with_token_at(&dir.path().join("directory-lock-marker"), dir.path()) + .unwrap_err(); + assert!( + directory_lock_error.contains("failed to open"), + "{directory_lock_error}" + ); +} + +#[test] +fn direct_generation_lock_identity_read_rejects_an_empty_legacy_lock() { + let dir = tempdir().unwrap(); + let marker = dir.path().join(GENERATION_FILE_NAME); + std::fs::write(&marker, b"generation-a\n").unwrap(); + std::fs::write(generation_lock_path(&marker), b"").unwrap(); + + let error = read_generation_lock_identity_path(&marker).unwrap_err(); + + assert!(error.contains("is empty"), "{error}"); +} + +#[cfg(windows)] +#[test] +fn windows_lock_path_decoder_rejects_odd_utf16_byte_length() { + let encoded = base64::engine::general_purpose::STANDARD.encode([0_u8]); + + let error = decode_lock_path(&encoded).unwrap_err(); + + assert!(error.contains("odd byte length"), "{error}"); +} + +#[test] +fn marker_rotation_preserves_the_lock_inode_identity() { + let dir = tempdir().unwrap(); + let path = dir.path().join(GENERATION_FILE_NAME); + write_new_generation(&path).unwrap(); + let lock_id = read_generation_lock_identity_path(&path).unwrap(); + let mut retirement = GenerationRetirement::acquire(&path).unwrap().unwrap(); + retirement.invalidate_for_replacement().unwrap(); + atomic_write(&path, b"generation-b\n").unwrap(); + retirement.commit_replacement(); + drop(retirement); + + InstallGeneration::capture(path.clone()).unwrap(); + + assert_eq!(read_generation_lock_identity_path(&path).unwrap(), lock_id); +} + +#[cfg(unix)] +#[test] +fn generation_reader_reports_directory_read_errors() { + let dir = tempdir().unwrap(); + let directory = File::open(dir.path()).unwrap(); + + let error = read_generation_marker(&directory, dir.path()).unwrap_err(); + + assert!( + error.contains("failed to read MCP install generation"), + "{error}" + ); +} diff --git a/crates/cli/tests/coverage/shared/installer_tests.rs b/crates/cli/tests/coverage/shared/installer_tests.rs new file mode 100644 index 000000000..e3908c6c7 --- /dev/null +++ b/crates/cli/tests/coverage/shared/installer_tests.rs @@ -0,0 +1,644 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use super::*; +use base64::Engine; +use std::path::Path; +use std::time::Duration; + +use reqwest::header::HeaderMap; +use serde_json::Value; + +use crate::agents::CodingAgent; + +struct BootstrapConfigHome { + _guard: std::sync::MutexGuard<'static, ()>, + previous: Option, +} + +impl BootstrapConfigHome { + fn enter(path: &std::path::Path) -> Self { + let guard = crate::test_support::ENV_TEST_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let previous = std::env::var_os("XDG_CONFIG_HOME"); + // SAFETY: This scope holds the process-wide environment mutex. + unsafe { std::env::set_var("XDG_CONFIG_HOME", path) }; + Self { + _guard: guard, + previous, + } + } +} + +impl Drop for BootstrapConfigHome { + fn drop(&mut self) { + // SAFETY: This scope still holds the process-wide environment mutex. + unsafe { + match self.previous.take() { + Some(previous) => std::env::set_var("XDG_CONFIG_HOME", previous), + None => std::env::remove_var("XDG_CONFIG_HOME"), + } + } + } +} + +#[tokio::test] +async fn transparent_hook_delivery_authenticates_the_wrapper_gateway() { + let _plugin_guard = crate::test_support::PLUGIN_CONFIG_TEST_LOCK.lock().await; + let temp = tempfile::tempdir().unwrap(); + let _bootstrap_home = BootstrapConfigHome::enter(&temp.path().join("xdg")); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let bind = listener.local_addr().unwrap(); + let gateway_url = format!("http://{bind}"); + let fingerprint = crate::configuration::transparent_gateway_fingerprint(&gateway_url); + let config = crate::configuration::GatewayConfig { + bind, + ..crate::configuration::GatewayConfig::default() + }; + let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel(); + let server = tokio::spawn(crate::server::serve_transparent_listener_with_dynamic( + listener, + config, + Vec::new(), + fingerprint.clone(), + Some(shutdown_rx), + )); + tokio::time::timeout(Duration::from_secs(5), async { + loop { + let url = gateway_url.clone(); + let fingerprint = fingerprint.clone(); + if tokio::task::spawn_blocking(move || { + crate::gateway::client::healthz_compatible(&url, &fingerprint) + }) + .await + .unwrap() + { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("wrapper gateway did not become healthy"); + let command = HookForwardRequest { + agent: CodingAgent::Codex, + gateway_url: Some(gateway_url.clone()), + generation_file: None, + generation_token: None, + forward_only: false, + transparent_run: true, + profile: None, + session_metadata: None, + gateway_mode: None, + fail_closed: true, + }; + let gateway = transparent_gateway_spec(&gateway_url).unwrap(); + + let response = send_verified_hook_forward_request( + &command, + &gateway, + &gateway_url, + json!({ + "session_id": "verified-transparent-hook", + "hook_event_name": "SessionStart" + }) + .to_string(), + ) + .await + .unwrap() + .unwrap(); + + assert_eq!(response.status, 200); + let _ = shutdown_tx.send(()); + tokio::time::timeout(Duration::from_secs(5), server) + .await + .expect("wrapper gateway did not stop") + .unwrap() + .unwrap(); +} + +#[test] +fn hook_payload_reader_normalizes_blank_input_and_accepts_the_exact_limit() { + assert_eq!(read_hook_payload_from(" \n\t".as_bytes(), 3).unwrap(), "{}"); + assert_eq!( + read_hook_payload_from("1234".as_bytes(), 4).unwrap(), + "1234" + ); +} + +#[test] +fn hook_payload_reader_rejects_oversized_invalid_and_unreadable_input() { + let oversized = read_hook_payload_from("12345".as_bytes(), 4) + .unwrap_err() + .to_string(); + assert!(oversized.contains("exceeds the 4-byte limit")); + + let invalid = read_hook_payload_from([0xff].as_slice(), 1) + .unwrap_err() + .to_string(); + assert!(invalid.contains("not valid UTF-8")); + + struct FailingReader; + impl std::io::Read for FailingReader { + fn read(&mut self, _buffer: &mut [u8]) -> std::io::Result { + Err(std::io::Error::other("synthetic hook input failure")) + } + } + assert!( + read_hook_payload_from(FailingReader, 4) + .unwrap_err() + .to_string() + .contains("synthetic hook input failure") + ); +} + +#[test] +fn explicit_persistent_destinations_ignore_ambient_urls() { + let destination = resolve_hook_destination( + Some("http://installed".into()), + Some("http://dynamic".into()), + false, + false, + ); + assert_eq!(destination.gateway_url, "http://installed"); + assert_eq!(destination.lifecycle, HookGatewayLifecycle::Existing); + + let destination = resolve_hook_destination(None, Some("http://dynamic".into()), false, false); + assert_eq!(destination.gateway_url, "http://dynamic"); + assert_eq!(destination.lifecycle, HookGatewayLifecycle::Transparent); + + let destination = resolve_hook_destination( + Some("http://source-plugin".into()), + Some("http://dynamic".into()), + true, + false, + ); + assert_eq!(destination.gateway_url, "http://source-plugin"); + assert_eq!(destination.lifecycle, HookGatewayLifecycle::Existing); + + let destination = resolve_hook_destination(None, Some("http://dynamic".into()), true, false); + assert_eq!(destination.gateway_url, crate::bootstrap::DEFAULT_URL); + assert_eq!(destination.lifecycle, HookGatewayLifecycle::Existing); + + let destination = resolve_hook_destination(Some("http://embedded".into()), None, false, true); + assert_eq!(destination.gateway_url, "http://embedded"); + assert_eq!(destination.lifecycle, HookGatewayLifecycle::Transparent); + + let destination = resolve_hook_destination(None, None, false, false); + assert_eq!(destination.gateway_url, crate::bootstrap::DEFAULT_URL); + assert_eq!(destination.lifecycle, HookGatewayLifecycle::Existing); +} + +#[test] +fn verified_hook_response_rejects_invalid_status_and_fail_open_http_errors() { + let error = handle_verified_hook_forward_response( + Ok(crate::gateway::client::VerifiedHttpResponse { + status: 0, + body: Vec::new(), + }), + true, + ) + .unwrap_err() + .to_string(); + assert!(error.contains("invalid status"), "{error}"); + + handle_verified_hook_forward_response( + Ok(crate::gateway::client::VerifiedHttpResponse { + status: 0, + body: Vec::new(), + }), + false, + ) + .unwrap(); + + handle_hook_forward_status(reqwest::StatusCode::BAD_GATEWAY, String::new(), false).unwrap(); +} + +#[test] +fn windows_hook_decoder_rejects_unsafe_odd_and_trailing_argument_envelopes() { + const SEPARATOR: &str = " -NoLogo -NoProfile -NonInteractive -EncodedCommand "; + #[cfg(windows)] + let launcher = windows_powershell_path().unwrap(); + #[cfg(not(windows))] + let launcher = "C:/Windows/System32/WindowsPowerShell/v1.0/powershell.exe".to_string(); + + assert!(decode_windows_hook_command(&format!("powershell.exe{SEPARATOR}QQ==")).is_none()); + assert!(decode_windows_hook_command(&format!("{launcher}{SEPARATOR}QQ==")).is_none()); + + let script = "$ErrorActionPreference='Stop'; & 'relay' ; if ($null -eq $LASTEXITCODE) { exit 1 }; exit $LASTEXITCODE"; + let encoded = base64::engine::general_purpose::STANDARD.encode( + script + .encode_utf16() + .flat_map(u16::to_le_bytes) + .collect::>(), + ); + assert!(decode_windows_hook_command(&format!("{launcher}{SEPARATOR}{encoded}")).is_none()); +} + +#[test] +fn merge_hooks_is_idempotent_and_preserves_existing_entries() { + let existing = json!({ + "hooks": { + "Stop": [{ "hooks": [{ "type": "command", "command": "existing" }] }] + } + }); + let generated = generated_hooks(CodingAgent::ClaudeCode, "nemo-relay hook-forward claude"); + let once = merge_hooks(existing, generated.clone()).unwrap(); + let twice = merge_hooks(once.clone(), generated).unwrap(); + assert_eq!(once, twice); + assert_eq!(twice["hooks"]["Stop"].as_array().unwrap().len(), 2); + assert_eq!( + twice["hooks"]["UserPromptExpansion"] + .as_array() + .unwrap() + .len(), + 1 + ); +} + +#[test] +fn merge_hooks_rejects_malformed_shapes() { + let generated = generated_hooks(CodingAgent::Codex, "cmd"); + assert!(merge_hooks(json!([]), generated.clone()).is_err()); + assert!(merge_hooks(json!({ "hooks": [] }), generated.clone()).is_err()); + assert!(merge_hooks(json!({ "hooks": { "Stop": {} } }), generated).is_err()); + assert!(merge_hooks(json!({}), json!({ "hooks": [] })).is_err()); +} + +#[test] +fn helper_formatting_and_headers_cover_optional_paths() { + assert!(event_matches_tools("PermissionRequest")); + assert!(!event_matches_tools("SessionStart")); + + let headers = gateway_headers( + Some("profile"), + Some(r#"{"team":"obs"}"#), + Some(GatewayMode::Passthrough), + ) + .unwrap(); + assert_eq!( + headers + .get("x-nemo-relay-gateway-mode") + .and_then(|value| value.to_str().ok()), + Some("passthrough") + ); + assert!( + insert_header( + &mut HeaderMap::new(), + "x-nemo-relay-config-profile", + Some("bad\nvalue") + ) + .is_err() + ); + + let headers = gateway_headers(None, None, None).unwrap(); + assert!(headers.is_empty()); +} + +#[test] +fn generated_hook_dispatch_covers_all_agents() { + for agent in [ + CodingAgent::ClaudeCode, + CodingAgent::Codex, + CodingAgent::Hermes, + ] { + assert!(generated_hooks(agent, "cmd")["hooks"].is_object()); + } + assert_eq!( + transparent_hook_forward_command_for_platform( + Path::new("nemo-relay"), + CodingAgent::Hermes, + "http://127.0.0.1:1234", + false, + ), + "nemo-relay hook-forward hermes --gateway-url http://127.0.0.1:1234 --transparent-run" + ); + assert_eq!( + transparent_hook_forward_command_for_platform( + Path::new("/abs/path/to/nemo-relay"), + CodingAgent::Codex, + "http://127.0.0.1:1234", + false, + ), + "/abs/path/to/nemo-relay hook-forward codex --gateway-url http://127.0.0.1:1234 --transparent-run" + ); + let relay = Path::new("/opt/NeMo Relay's & tools/nemo-relay"); + assert_eq!( + transparent_hook_forward_command_for_platform( + relay, + CodingAgent::Codex, + "http://127.0.0.1:1234", + false + ), + r#"'/opt/NeMo Relay'\''s & tools/nemo-relay' hook-forward codex --gateway-url http://127.0.0.1:1234 --transparent-run"# + ); + let native = transparent_hook_forward_command( + Path::new("nemo-relay"), + CodingAgent::Hermes, + "http://127.0.0.1:1234", + ) + .unwrap(); + if cfg!(windows) { + assert_eq!( + decode_windows_hook_command(&native).unwrap(), + vec![ + String::from("nemo-relay"), + String::from("hook-forward"), + String::from("hermes"), + String::from("--gateway-url"), + String::from("http://127.0.0.1:1234"), + String::from("--transparent-run"), + ] + ); + } else { + assert_eq!( + native, + transparent_hook_forward_command_for_platform( + Path::new("nemo-relay"), + CodingAgent::Hermes, + "http://127.0.0.1:1234", + false, + ) + ); + } + let windows = transparent_hook_forward_command_for_platform( + relay, + CodingAgent::ClaudeCode, + "http://127.0.0.1:1234", + true, + ); + let (launcher, encoded) = windows.rsplit_once(' ').unwrap(); + assert_eq!( + launcher, + "C:/Windows/System32/WindowsPowerShell/v1.0/powershell.exe -NoLogo -NoProfile -NonInteractive -EncodedCommand" + ); + assert!( + !encoded.is_empty() + && encoded + .chars() + .all(|character| character.is_ascii_alphanumeric() + || matches!(character, '+' | '/' | '=')) + ); + assert_eq!( + decode_windows_hook_command(&windows).unwrap(), + vec![ + relay.display().to_string(), + "hook-forward".into(), + "claude".into(), + "--gateway-url".into(), + "http://127.0.0.1:1234".into(), + "--transparent-run".into(), + ] + ); + assert!(decode_windows_hook_command("powershell.exe -EncodedCommand invalid").is_none()); + assert!( + decode_windows_hook_command( + "C:/Windows/System32/WindowsPowerShell/v1.0/powershell.exe -NoLogo -NoProfile -NonInteractive -EncodedCommand invalid payload" + ) + .is_none() + ); + let oversized = format!( + "C:/Windows/System32/WindowsPowerShell/v1.0/powershell.exe -NoLogo -NoProfile -NonInteractive -EncodedCommand {}", + "A".repeat(8_000) + ); + assert!(decode_windows_hook_command(&oversized).is_none()); + + let oversized_path = format!("C:/{}nemo-relay.exe", "long/".repeat(2_000)); + let error = encoded_windows_hook_command( + "C:/Windows/System32/WindowsPowerShell/v1.0/powershell.exe", + Path::new(&oversized_path), + &["hook-forward".into(), "codex".into()], + ) + .unwrap_err(); + assert!(error.contains("exceeds the 8000-character safety limit")); + assert!(error.contains("shorten the Relay or plugin installation path")); +} + +#[test] +fn codex_generation_uses_exactly_the_supported_hook_schema() { + let generated = generated_hooks(CodingAgent::Codex, "cmd"); + let events = generated["hooks"] + .as_object() + .unwrap() + .keys() + .map(String::as_str) + .collect::>(); + + assert_eq!( + events, + std::collections::BTreeSet::from([ + "PermissionRequest", + "PostCompact", + "PostToolUse", + "PreCompact", + "PreToolUse", + "SessionStart", + "Stop", + "SubagentStart", + "SubagentStop", + "UserPromptSubmit", + ]) + ); + for unsupported in ["PostToolUseFailure", "Notification", "SessionEnd"] { + assert!(generated["hooks"].get(unsupported).is_none()); + } +} + +#[test] +fn packaged_hook_configs_are_valid_json() { + let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../integrations/coding-agents"); + for path in [ + root.join("../../.agents/plugins/marketplace.json"), + root.join("../../.claude-plugin/marketplace.json"), + root.join("claude-code/hooks/hooks.json"), + root.join("codex/hooks/hooks.json"), + root.join("claude-code/.mcp.json"), + root.join("codex/.mcp.json"), + root.join("claude-code/.claude-plugin/plugin.json"), + root.join("codex/.codex-plugin/plugin.json"), + ] { + let raw = std::fs::read_to_string(&path).unwrap(); + serde_json::from_str::(&raw) + .unwrap_or_else(|error| panic!("{} is invalid JSON: {error}", path.display())); + } +} + +#[test] +fn packaged_plugin_hooks_use_expected_forwarding_commands() { + let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../integrations/coding-agents"); + let claude = serde_json::from_str::( + &std::fs::read_to_string(root.join("claude-code/hooks/hooks.json")).unwrap(), + ) + .unwrap(); + let codex = serde_json::from_str::( + &std::fs::read_to_string(root.join("codex/hooks/hooks.json")).unwrap(), + ) + .unwrap(); + + assert_eq!( + codex["description"], + json!("SPDX-License-Identifier: Apache-2.0") + ); + assert_eq!( + codex.as_object().unwrap().keys().collect::>(), + vec!["description", "hooks"] + ); + + assert_eq!( + claude["hooks"]["SessionStart"][0]["hooks"][0]["command"], + json!(format!( + "nemo-relay hook-forward claude --gateway-url {} --forward-only", + crate::bootstrap::DEFAULT_URL + )) + ); + assert_eq!( + codex["hooks"]["SessionStart"][0]["hooks"][0]["command"], + json!(format!( + "nemo-relay hook-forward codex --gateway-url {} --forward-only", + crate::bootstrap::DEFAULT_URL + )) + ); + assert_eq!( + claude["hooks"], + generated_hooks( + CodingAgent::ClaudeCode, + &format!( + "nemo-relay hook-forward claude --gateway-url {} --forward-only", + crate::bootstrap::DEFAULT_URL + ), + )["hooks"] + ); + assert_eq!( + codex["hooks"], + generated_hooks( + CodingAgent::Codex, + &format!( + "nemo-relay hook-forward codex --gateway-url {} --forward-only", + crate::bootstrap::DEFAULT_URL + ), + )["hooks"] + ); + assert!( + claude["hooks"] + .as_object() + .unwrap() + .values() + .flat_map(|groups| groups.as_array().unwrap()) + .flat_map(|group| group["hooks"].as_array().unwrap()) + .all(|hook| hook["command"] + .as_str() + .is_some_and(|command| command.starts_with("nemo-relay "))) + ); + assert!( + codex["hooks"] + .as_object() + .unwrap() + .values() + .flat_map(|groups| groups.as_array().unwrap()) + .flat_map(|group| group["hooks"].as_array().unwrap()) + .all(|hook| hook["command"] + .as_str() + .is_some_and(|command| command.starts_with("nemo-relay "))) + ); +} + +#[test] +fn packaged_plugin_manifests_use_stable_plugin_name_and_version() { + let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../integrations/coding-agents"); + let claude_path = root.join("claude-code/.claude-plugin/plugin.json"); + let claude = + serde_json::from_str::(&std::fs::read_to_string(&claude_path).unwrap()).unwrap(); + assert_eq!(claude["name"], json!("nemo-relay-plugin")); + assert_eq!(claude["version"], json!(env!("CARGO_PKG_VERSION"))); + assert!(claude.get("hooks").is_none()); + assert_eq!(claude["mcpServers"], json!("./.mcp.json")); + + let codex_path = root.join("codex/.codex-plugin/plugin.json"); + let codex = + serde_json::from_str::(&std::fs::read_to_string(&codex_path).unwrap()).unwrap(); + assert_eq!(codex["name"], json!("nemo-relay-plugin")); + assert_eq!(codex["version"], json!(env!("CARGO_PKG_VERSION"))); + assert!(codex.get("hooks").is_none()); + assert_eq!(codex["mcpServers"], json!("./.mcp.json")); + + let codex_mcp_path = root.join("codex/.mcp.json"); + let codex_mcp = + serde_json::from_str::(&std::fs::read_to_string(&codex_mcp_path).unwrap()).unwrap(); + let server = &codex_mcp["nemo-relay"]; + assert_eq!(server["command"], json!("nemo-relay")); + assert_eq!(server["args"], json!(["mcp"])); + assert_eq!( + server["env"], + json!({"NEMO_RELAY_GATEWAY_BIND": "127.0.0.1:47632"}) + ); + assert_eq!(server["required"], json!(true)); + assert_eq!(server["startup_timeout_sec"], json!(20)); + assert_eq!( + server["env_vars"], + json!(crate::mcp_environment::forwarded_names_for_platform( + Vec::new(), + None, + false, + )) + ); + + let claude_mcp_path = root.join("claude-code/.mcp.json"); + let claude_mcp = + serde_json::from_str::(&std::fs::read_to_string(&claude_mcp_path).unwrap()).unwrap(); + let claude_server = &claude_mcp["mcpServers"]["nemo-relay"]; + assert_eq!(claude_server["command"], json!("nemo-relay")); + assert_eq!(claude_server["args"], json!(["mcp"])); + assert_eq!( + claude_server["env"], + json!({"NEMO_RELAY_GATEWAY_BIND": "127.0.0.1:47632"}) + ); + assert_eq!(claude_server["alwaysLoad"], json!(true)); + + let codex_marketplace_path = root.join("../../.agents/plugins/marketplace.json"); + let codex_marketplace = + serde_json::from_str::(&std::fs::read_to_string(&codex_marketplace_path).unwrap()) + .unwrap(); + assert_eq!(codex_marketplace["name"], json!("nemo-relay")); + assert_eq!( + codex_marketplace["plugins"][0]["name"], + json!("nemo-relay-plugin") + ); + assert_eq!( + codex_marketplace["plugins"][0]["source"]["path"], + json!("./integrations/coding-agents/codex") + ); + + let claude_marketplace_path = root.join("../../.claude-plugin/marketplace.json"); + let claude_marketplace = + serde_json::from_str::(&std::fs::read_to_string(&claude_marketplace_path).unwrap()) + .unwrap(); + assert_eq!(claude_marketplace["name"], json!("nemo-relay")); + assert_eq!( + claude_marketplace["plugins"][0]["name"], + json!("nemo-relay-plugin") + ); + assert_eq!( + claude_marketplace["plugins"][0]["source"], + json!("./integrations/coding-agents/claude-code") + ); +} + +#[test] +fn packaged_plugin_helpers_are_present() { + let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../integrations/coding-agents"); + for path in [ + root.join("claude-code/hooks/hooks.json"), + root.join("codex/hooks/hooks.json"), + root.join("claude-code/.mcp.json"), + root.join("codex/.mcp.json"), + ] { + let metadata = std::fs::metadata(&path) + .unwrap_or_else(|error| panic!("{} missing: {error}", path.display())); + assert!(metadata.is_file(), "{} is not a file", path.display()); + } +} diff --git a/crates/cli/tests/coverage/shared/mcp_gateway_tests.rs b/crates/cli/tests/coverage/shared/mcp_gateway_tests.rs new file mode 100644 index 000000000..77850fd0f --- /dev/null +++ b/crates/cli/tests/coverage/shared/mcp_gateway_tests.rs @@ -0,0 +1,296 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use super::*; + +#[test] +fn recovery_tracks_gateway_instances_instead_of_the_local_starter() { + let mut recovery = RecoveryState::new("first".into()); + + recovery.observe("first".into()).unwrap(); + recovery.require_restart().unwrap(); + recovery.observe("second".into()).unwrap(); + assert_eq!(recovery.instance_id(), "second"); + assert!(recovery.require_restart().is_err()); +} + +#[test] +fn observing_two_replacements_exhausts_the_single_restart_allowance() { + let mut recovery = RecoveryState::new("first".into()); + recovery.observe("second".into()).unwrap(); + + let error = recovery.observe("third".into()).unwrap_err(); + + assert!(error.to_string().contains("replaced again")); +} + +#[tokio::test(start_paused = true)] +async fn production_heartbeat_recovers_after_one_thirty_second_interval() { + let (restarted_tx, restarted_rx) = tokio::sync::oneshot::channel(); + let mut restarted_tx = Some(restarted_tx); + let monitor = tokio::spawn(maintain_gateway_with( + "127.0.0.1:47632".parse().unwrap(), + "http://gateway".into(), + Duration::from_secs(30), + |_url| async { Ok(false) }, + move |address, _expected_instance| { + let sender = restarted_tx.take(); + async move { + if let Some(sender) = sender { + let _ = sender.send(()); + } + Ok(crate::bootstrap::GatewayEndpoint { + address, + url: "http://recovered".into(), + instance_id: "recovered".into(), + }) + } + }, + )); + + tokio::task::yield_now().await; + tokio::time::advance(Duration::from_secs(30)).await; + restarted_rx.await.unwrap(); + assert!(!monitor.is_finished()); + monitor.abort(); +} + +#[tokio::test(start_paused = true)] +async fn lifecycle_retirement_is_checked_before_a_healthy_heartbeat() { + use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; + + let health_calls = Arc::new(AtomicUsize::new(0)); + let health_calls_for_probe = health_calls.clone(); + let monitor = tokio::spawn(maintain_gateway_instances_with_generation( + "127.0.0.1:47632".parse().unwrap(), + crate::bootstrap::GatewayEndpoint { + address: "127.0.0.1:47632".parse().unwrap(), + url: "http://gateway".into(), + instance_id: "first".into(), + }, + Duration::from_secs(30), + move |_url, _expected| { + health_calls_for_probe.fetch_add(1, Ordering::SeqCst); + async { Ok(Some("replacement".into())) } + }, + |_address, _expected| async { panic!("retired lifecycle attempted recovery") }, + || async { Err(CliError::Launch("generation retired".into())) }, + )); + + tokio::time::advance(Duration::from_secs(30)).await; + let error = monitor.await.unwrap().unwrap_err(); + + assert!(error.to_string().contains("generation retired")); + assert_eq!(health_calls.load(Ordering::SeqCst), 0); +} + +#[tokio::test] +async fn lifecycle_retirement_during_health_is_checked_before_adoption() { + use std::sync::Arc; + use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + + let retired = Arc::new(AtomicBool::new(false)); + let health_calls = Arc::new(AtomicUsize::new(0)); + let retired_during_health = retired.clone(); + let health_calls_for_probe = health_calls.clone(); + let retired_for_verification = retired.clone(); + let monitor = tokio::spawn(maintain_gateway_instances_with_generation( + "127.0.0.1:47632".parse().unwrap(), + crate::bootstrap::GatewayEndpoint { + address: "127.0.0.1:47632".parse().unwrap(), + url: "http://gateway".into(), + instance_id: "first".into(), + }, + Duration::from_millis(1), + move |_url, _expected| { + health_calls_for_probe.fetch_add(1, Ordering::SeqCst); + retired_during_health.store(true, Ordering::SeqCst); + async { Ok(Some("replacement".into())) } + }, + |_address, _expected| async { panic!("retired lifecycle attempted recovery") }, + move || { + let retired = retired_for_verification.load(Ordering::SeqCst); + async move { + if retired { + Err(CliError::Launch("generation retired during health".into())) + } else { + Ok(()) + } + } + }, + )); + + let error = tokio::time::timeout(Duration::from_secs(1), monitor) + .await + .unwrap() + .unwrap() + .unwrap_err(); + + assert!(error.to_string().contains("retired during health")); + assert_eq!(health_calls.load(Ordering::SeqCst), 1); +} + +#[test] +fn generation_transaction_polling_is_cancellable_for_clean_mcp_shutdown() { + let dir = tempfile::tempdir().unwrap(); + let path = dir + .path() + .join(crate::installation::generation::GENERATION_FILE_NAME); + crate::installation::generation::write_new_generation(&path).unwrap(); + let generation = + crate::installation::generation::InstallGeneration::capture(path.clone()).unwrap(); + let mut retirement = crate::installation::generation::GenerationRetirement::acquire(&path) + .unwrap() + .unwrap(); + retirement.invalidate_for_replacement().unwrap(); + + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + runtime.block_on(async { + let verification = tokio::spawn(verify_lifecycle_async(Some(generation))); + tokio::time::sleep(Duration::from_millis(50)).await; + assert!(!verification.is_finished()); + verification.abort(); + let error = tokio::time::timeout(Duration::from_millis(250), verification) + .await + .expect("generation lifecycle poll ignored MCP cancellation") + .unwrap_err(); + assert!(error.is_cancelled()); + }); + retirement.restore_after_rollback().unwrap(); +} +#[tokio::test] +async fn concurrent_clients_consume_the_same_replacement_allowance() { + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::{Arc, Mutex}; + + let current = Arc::new(Mutex::new(Some("first".to_string()))); + let restart_count = Arc::new(AtomicUsize::new(0)); + let observed_replacement = Arc::new(AtomicUsize::new(0)); + let mut monitors = Vec::new(); + for _ in 0..2 { + let current_for_health = current.clone(); + let current_for_restart = current.clone(); + let restart_count = restart_count.clone(); + let observed_replacement = observed_replacement.clone(); + monitors.push(tokio::spawn(maintain_gateway_instances_with_generation( + "127.0.0.1:47632".parse().unwrap(), + crate::bootstrap::GatewayEndpoint { + address: "127.0.0.1:47632".parse().unwrap(), + url: "http://gateway".into(), + instance_id: "first".into(), + }, + Duration::from_millis(1), + move |_url, expected| { + let current = current_for_health.lock().unwrap().clone(); + if expected == "second" && current.as_deref() == Some("second") { + observed_replacement.fetch_add(1, Ordering::SeqCst); + } + async move { Ok(current) } + }, + move |address, _expected_instance| { + let current = current_for_restart.clone(); + let restart_count = restart_count.clone(); + async move { + let mut current = current.lock().unwrap(); + let started = current.is_none(); + if started { + *current = Some("second".into()); + restart_count.fetch_add(1, Ordering::SeqCst); + } + Ok(crate::bootstrap::GatewayEndpoint { + address, + url: "http://gateway".into(), + instance_id: current.clone().unwrap(), + }) + } + }, + || async { Ok(()) }, + ))); + } + + *current.lock().unwrap() = None; + tokio::time::timeout(Duration::from_secs(2), async { + while observed_replacement.load(Ordering::SeqCst) < 2 { + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + assert_eq!(restart_count.load(Ordering::SeqCst), 1); + + *current.lock().unwrap() = None; + for monitor in monitors { + let error = tokio::time::timeout(Duration::from_secs(2), monitor) + .await + .unwrap() + .unwrap() + .unwrap_err(); + assert!(error.to_string().contains("after its coordinated restart")); + } + assert_eq!(restart_count.load(Ordering::SeqCst), 1); +} + +#[tokio::test] +async fn dropping_gateway_lease_aborts_its_monitor() { + struct NotifyOnDrop(Option>); + + impl Drop for NotifyOnDrop { + fn drop(&mut self) { + if let Some(sender) = self.0.take() { + let _ = sender.send(()); + } + } + } + + let (started_tx, started_rx) = tokio::sync::oneshot::channel(); + let (dropped_tx, dropped_rx) = tokio::sync::oneshot::channel(); + let monitor = tokio::spawn(async move { + let _notify = NotifyOnDrop(Some(dropped_tx)); + let _ = started_tx.send(()); + std::future::pending::<()>().await; + #[allow(unreachable_code)] + Ok(()) + }); + started_rx.await.unwrap(); + + drop(GatewayLease { + monitor, + shutdown: Arc::new(LeaseShutdown::default()), + }); + + tokio::time::timeout(Duration::from_secs(1), dropped_rx) + .await + .expect("gateway monitor was not aborted when its lease dropped") + .unwrap(); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn dropping_gateway_lease_waits_for_inflight_recovery() { + let shutdown = Arc::new(LeaseShutdown::default()); + let recovery = shutdown.start_recovery().unwrap(); + let (release_tx, release_rx) = std::sync::mpsc::channel(); + let worker = std::thread::spawn(move || { + release_rx.recv().unwrap(); + drop(recovery); + }); + let monitor = tokio::spawn(std::future::pending::>()); + let lease = GatewayLease { monitor, shutdown }; + let (dropped_tx, dropped_rx) = tokio::sync::oneshot::channel(); + let dropper = std::thread::spawn(move || { + drop(lease); + let _ = dropped_tx.send(()); + }); + + assert!( + tokio::time::timeout(Duration::from_millis(50), dropped_rx) + .await + .is_err() + ); + release_tx.send(()).unwrap(); + dropper.join().unwrap(); + worker.join().unwrap(); +} diff --git a/crates/cli/tests/coverage/shared/mcp_tests.rs b/crates/cli/tests/coverage/shared/mcp_tests.rs new file mode 100644 index 000000000..bf84ab938 --- /dev/null +++ b/crates/cli/tests/coverage/shared/mcp_tests.rs @@ -0,0 +1,804 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use serde_json::{Value, json}; +use std::ffi::OsString; +use std::io::{BufReader as StdBufReader, Cursor}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; +use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}; + +use super::*; +use crate::installation::generation::{ + GENERATION_FILE_NAME, GenerationRetirement, InstallGeneration, write_new_generation, +}; +use crate::mcp::protocol::MCP_SUPPORTED_PROTOCOL_VERSIONS; + +struct BootstrapConfigHome { + _guard: std::sync::MutexGuard<'static, ()>, + previous: Option, +} + +impl BootstrapConfigHome { + fn enter(path: &std::path::Path) -> Self { + let guard = crate::test_support::ENV_TEST_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let previous = std::env::var_os("XDG_CONFIG_HOME"); + unsafe { std::env::set_var("XDG_CONFIG_HOME", path) }; + Self { + _guard: guard, + previous, + } + } +} + +impl Drop for BootstrapConfigHome { + fn drop(&mut self) { + if let Some(previous) = self.previous.take() { + unsafe { std::env::set_var("XDG_CONFIG_HOME", previous) }; + } else { + unsafe { std::env::remove_var("XDG_CONFIG_HOME") }; + } + } +} + +struct TransparentRunEnvironment { + _guard: std::sync::MutexGuard<'static, ()>, + previous_run: Option, + previous_gateway: Option, +} + +impl TransparentRunEnvironment { + fn without_gateway() -> Self { + let guard = crate::test_support::ENV_TEST_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let previous_run = std::env::var_os(crate::configuration::TRANSPARENT_RUN_ENV); + let previous_gateway = std::env::var_os(crate::configuration::GATEWAY_URL_ENV); + // SAFETY: This scope holds the process-wide environment mutex. + unsafe { + std::env::set_var(crate::configuration::TRANSPARENT_RUN_ENV, "1"); + std::env::remove_var(crate::configuration::GATEWAY_URL_ENV); + } + Self { + _guard: guard, + previous_run, + previous_gateway, + } + } +} + +impl Drop for TransparentRunEnvironment { + fn drop(&mut self) { + // SAFETY: This restores the process environment while the mutex remains held. + unsafe { + match self.previous_run.take() { + Some(value) => std::env::set_var(crate::configuration::TRANSPARENT_RUN_ENV, value), + None => std::env::remove_var(crate::configuration::TRANSPARENT_RUN_ENV), + } + match self.previous_gateway.take() { + Some(value) => std::env::set_var(crate::configuration::GATEWAY_URL_ENV, value), + None => std::env::remove_var(crate::configuration::GATEWAY_URL_ENV), + } + } + } +} + +#[tokio::test] +async fn transparent_mcp_requires_the_wrapper_gateway_url() { + let _environment = TransparentRunEnvironment::without_gateway(); + + let error = run(&crate::server::GatewayOverrides::default()) + .await + .unwrap_err() + .to_string(); + + assert!( + error.contains(crate::configuration::GATEWAY_URL_ENV), + "{error}" + ); + assert!( + error.contains(crate::configuration::TRANSPARENT_RUN_ENV), + "{error}" + ); +} + +#[test] +fn bounded_mcp_reader_accepts_the_limit_and_preserves_following_frames() { + let mut input = vec![b'a'; MAX_MCP_FRAME_BYTES - 1]; + input.push(b'\n'); + input.extend_from_slice(b"{}\n"); + let mut reader = StdBufReader::new(Cursor::new(input)); + let mut frame = Vec::new(); + + assert_eq!( + read_bounded_frame(&mut reader, &mut frame, MAX_MCP_FRAME_BYTES).unwrap(), + MAX_MCP_FRAME_BYTES + ); + assert_eq!(frame.last(), Some(&b'\n')); + frame.clear(); + assert_eq!( + read_bounded_frame(&mut reader, &mut frame, MAX_MCP_FRAME_BYTES).unwrap(), + 3 + ); + assert_eq!(frame, b"{}\n"); +} + +#[test] +fn bounded_mcp_reader_rejects_one_oversized_unterminated_frame() { + let input = vec![b'a'; MAX_MCP_FRAME_BYTES + 1]; + let mut reader = StdBufReader::new(Cursor::new(input)); + let mut frame = Vec::new(); + + let error = read_bounded_frame(&mut reader, &mut frame, MAX_MCP_FRAME_BYTES).unwrap_err(); + + assert_eq!(error.kind(), std::io::ErrorKind::InvalidData); + assert!(error.to_string().contains("MCP frame exceeds")); +} + +#[test] +fn initialize_reports_native_server_and_supported_protocol() { + for protocol_version in MCP_SUPPORTED_PROTOCOL_VERSIONS { + let response = response_for(&json!({ + "jsonrpc": "2.0", + "id": 7, + "method": "initialize", + "params": { "protocolVersion": protocol_version } + })) + .unwrap(); + + assert_eq!(response["jsonrpc"], json!("2.0")); + assert_eq!(response["id"], json!(7)); + assert_eq!( + response["result"]["protocolVersion"], + json!(protocol_version) + ); + assert_eq!(response["result"]["capabilities"], json!({})); + assert_eq!( + response["result"]["serverInfo"]["name"], + json!("nemo-relay") + ); + assert_eq!( + response["result"]["serverInfo"]["version"], + json!(env!("CARGO_PKG_VERSION")) + ); + } + + let response = response_for(&json!({ + "jsonrpc": "2.0", + "id": 7, + "method": "initialize", + "params": { "protocolVersion": "2024-11-05" } + })) + .unwrap(); + assert_eq!( + response["result"]["protocolVersion"], + json!(MCP_PROTOCOL_VERSION) + ); +} + +#[test] +fn supported_requests_and_notifications_have_minimal_mcp_behavior() { + assert_eq!( + response_for(&json!({"jsonrpc":"2.0", "id":"tools", "method":"tools/list"})), + Some(json!({"jsonrpc":"2.0", "id":"tools", "result":{"tools":[]}})) + ); + assert_eq!( + response_for(&json!({"jsonrpc":"2.0", "id":2, "method":"ping"})), + Some(json!({"jsonrpc":"2.0", "id":2, "result":{}})) + ); + assert_eq!( + response_for(&json!({ + "jsonrpc":"2.0", + "method":"notifications/initialized" + })), + None + ); +} + +#[test] +fn invalid_and_unknown_requests_return_jsonrpc_errors() { + assert_eq!( + response_for(&json!({"jsonrpc":"2.0", "id":3})), + Some(jsonrpc_error(json!(3), -32600, "Invalid Request")) + ); + assert_eq!( + response_for(&json!({"jsonrpc":"2.0", "id":4, "method":"resources/list"})), + Some(jsonrpc_error(json!(4), -32601, "Method not found")) + ); + assert_eq!( + response_for(&json!({"jsonrpc":"2.0", "id":5, "method":"initialize", "params":{}})), + Some(jsonrpc_error(json!(5), -32602, "Missing protocolVersion")) + ); + assert_eq!( + response_for(&json!({"jsonrpc":"2.0"})), + Some(jsonrpc_error(Value::Null, -32600, "Invalid Request")) + ); + assert_eq!( + response_for(&json!({"jsonrpc":"2.0", "id":true, "method":"ping"})), + Some(jsonrpc_error(Value::Null, -32600, "Invalid Request")) + ); + assert_eq!( + response_for(&json!({"jsonrpc":"2.0", "method":7})), + Some(jsonrpc_error(Value::Null, -32600, "Invalid Request")) + ); +} + +#[test] +fn invalid_jsonrpc_returns_invalid_request() { + let action = crate::mcp::protocol::evaluate_frame( + r#"{"jsonrpc":"1.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18"}}"#, + ); + + assert_eq!( + action.response, + Some(jsonrpc_error(json!(1), -32600, "Invalid Request")) + ); +} + +#[tokio::test] +async fn stdio_loop_recovers_from_parse_errors_and_ignores_notifications() { + let (mut client, server) = tokio::io::duplex(4096); + let (server_reader, server_writer) = tokio::io::split(server); + let task = tokio::spawn(serve_stdio(BufReader::new(server_reader), server_writer)); + + client + .write_all( + b"not-json\n{\"jsonrpc\":\"2.0\",\"method\":\"notifications/initialized\"}\n{\"jsonrpc\":\"2.0\",\"id\":5,\"method\":\"ping\"}\n", + ) + .await + .unwrap(); + client.shutdown().await.unwrap(); + let mut output = String::new(); + client.read_to_string(&mut output).await.unwrap(); + task.await.unwrap().unwrap(); + + let responses = output + .lines() + .map(|line| serde_json::from_str::(line).unwrap()) + .collect::>(); + assert_eq!(responses.len(), 2); + assert_eq!( + responses[0], + jsonrpc_error(Value::Null, -32700, "Parse error") + ); + assert_eq!(responses[1], json!({"jsonrpc":"2.0", "id":5, "result":{}})); +} + +#[tokio::test] +async fn mcp_session_serves_stdio_and_stops_heartbeat_on_eof() { + let (client, server_io) = tokio::io::duplex(4096); + let (client_reader, mut client_writer) = tokio::io::split(client); + let (server_reader, server_writer) = tokio::io::split(server_io); + let task = tokio::spawn(run_session(BufReader::new(server_reader), server_writer)); + + client_writer + .write_all( + b"{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{\"protocolVersion\":\"2025-06-18\"}}\n", + ) + .await + .unwrap(); + let mut client_reader = BufReader::new(client_reader); + let mut response = String::new(); + tokio::time::timeout( + std::time::Duration::from_secs(5), + client_reader.read_line(&mut response), + ) + .await + .expect("MCP initialization response timed out") + .unwrap(); + assert_eq!( + serde_json::from_str::(&response).unwrap()["result"]["serverInfo"]["name"], + json!("nemo-relay") + ); + + client_writer.shutdown().await.unwrap(); + tokio::time::timeout(std::time::Duration::from_secs(5), task) + .await + .expect("MCP session did not stop after stdin EOF") + .unwrap() + .unwrap(); +} + +#[tokio::test] +async fn heartbeat_keeps_a_compatible_gateway_session_alive() { + let _plugin_guard = crate::test_support::PLUGIN_CONFIG_TEST_LOCK.lock().await; + let temp = tempfile::tempdir().unwrap(); + let _bootstrap_home = BootstrapConfigHome::enter(&temp.path().join("xdg")); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let bind = listener.local_addr().unwrap(); + let config = crate::configuration::GatewayConfig { + bind, + ..crate::configuration::GatewayConfig::default() + }; + let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel(); + let fingerprint = "test-fingerprint"; + let gateway = tokio::spawn(crate::server::serve_listener_with_bootstrap( + listener, + config, + fingerprint.into(), + Some(shutdown_rx), + )); + let url = format!("http://{bind}"); + tokio::time::timeout(Duration::from_secs(5), async { + loop { + let probe_url = url.clone(); + if tokio::task::spawn_blocking(move || { + crate::gateway::client::healthz_compatible(&probe_url, fingerprint) + }) + .await + .unwrap() + { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("compatible gateway did not become healthy"); + + let health_calls = Arc::new(AtomicUsize::new(0)); + let restart_calls = Arc::new(AtomicUsize::new(0)); + let (observed_tx, mut observed_rx) = tokio::sync::mpsc::unbounded_channel(); + let heartbeat = tokio::spawn(maintain_gateway_with( + bind, + url, + Duration::from_millis(10), + { + let health_calls = health_calls.clone(); + move |url| { + let health_calls = health_calls.clone(); + let observed_tx = observed_tx.clone(); + async move { + let healthy = tokio::task::spawn_blocking(move || { + crate::gateway::client::healthz_compatible(&url, fingerprint) + }) + .await + .map_err(|error| { + CliError::Launch(format!("gateway heartbeat task failed: {error}")) + })?; + if healthy { + let call = health_calls.fetch_add(1, Ordering::SeqCst) + 1; + if call == 3 { + let _ = observed_tx.send(()); + } + } + Ok(healthy) + } + } + }, + { + let restart_calls = restart_calls.clone(); + move |address, _expected_instance| { + restart_calls.fetch_add(1, Ordering::SeqCst); + async move { + Ok(crate::bootstrap::GatewayEndpoint { + address, + url: "http://unexpected-restart".into(), + instance_id: "unexpected".into(), + }) + } + } + }, + )); + + tokio::time::timeout(Duration::from_secs(5), observed_rx.recv()) + .await + .expect("heartbeat did not complete three compatible health checks") + .expect("heartbeat stopped before completing three compatible health checks"); + assert!(!heartbeat.is_finished()); + assert!(health_calls.load(Ordering::SeqCst) >= 3); + assert_eq!(restart_calls.load(Ordering::SeqCst), 0); + heartbeat.abort(); + assert!(heartbeat.await.unwrap_err().is_cancelled()); + + let _ = shutdown_tx.send(()); + tokio::time::timeout(Duration::from_secs(5), gateway) + .await + .expect("compatible gateway did not stop") + .unwrap() + .unwrap(); +} + +#[tokio::test] +async fn borrowed_transparent_gateway_is_authenticated_and_monitored() { + let _plugin_guard = crate::test_support::PLUGIN_CONFIG_TEST_LOCK.lock().await; + let temp = tempfile::tempdir().unwrap(); + let _bootstrap_home = BootstrapConfigHome::enter(&temp.path().join("xdg")); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let bind = listener.local_addr().unwrap(); + let url = format!("http://{bind}"); + let fingerprint = crate::configuration::transparent_gateway_fingerprint(&url); + let config = crate::configuration::GatewayConfig { + bind, + ..crate::configuration::GatewayConfig::default() + }; + let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel(); + let gateway = tokio::spawn(crate::server::serve_transparent_listener_with_dynamic( + listener, + config, + Vec::new(), + fingerprint.clone(), + Some(shutdown_rx), + )); + tokio::time::timeout(Duration::from_secs(5), async { + loop { + let probe_url = url.clone(); + let probe_fingerprint = fingerprint.clone(); + if tokio::task::spawn_blocking(move || { + crate::gateway::client::healthz_compatible(&probe_url, &probe_fingerprint) + }) + .await + .unwrap() + { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("transparent gateway did not become healthy"); + + let mut lease = + gateway::GatewayLease::borrow_with_interval(url, fingerprint, Duration::from_millis(10)) + .await + .expect("authenticated gateway should be borrowable"); + let _ = shutdown_tx.send(()); + tokio::time::timeout(Duration::from_secs(5), gateway) + .await + .expect("transparent gateway did not stop") + .unwrap() + .unwrap(); + + let error = tokio::time::timeout(Duration::from_secs(5), lease.wait()) + .await + .expect("borrowed gateway heartbeat did not detect shutdown") + .unwrap_err() + .to_string(); + assert!(error.contains("no longer available"), "{error}"); +} + +#[tokio::test] +async fn heartbeat_performs_one_restart_and_tracks_the_recovered_gateway() { + let bind = "127.0.0.1:47632".parse().unwrap(); + let restart_calls = Arc::new(AtomicUsize::new(0)); + let observed_urls = Arc::new(Mutex::new(Vec::new())); + let (recovered_tx, mut recovered_rx) = tokio::sync::mpsc::unbounded_channel(); + let heartbeat = tokio::spawn(maintain_gateway_with( + bind, + "http://dead-gateway".into(), + Duration::from_millis(1), + { + let observed_urls = observed_urls.clone(); + move |url| { + let observed_urls = observed_urls.clone(); + let recovered_tx = recovered_tx.clone(); + async move { + let recovered = url == "http://recovered-gateway"; + observed_urls.lock().unwrap().push(url); + if recovered { + let _ = recovered_tx.send(()); + } + Ok(recovered) + } + } + }, + { + let restart_calls = restart_calls.clone(); + move |address, _expected_instance| { + let restart_calls = restart_calls.clone(); + async move { + restart_calls.fetch_add(1, Ordering::SeqCst); + Ok(crate::bootstrap::GatewayEndpoint { + address, + url: "http://recovered-gateway".into(), + instance_id: "recovered".into(), + }) + } + } + }, + )); + + tokio::time::timeout(Duration::from_secs(5), recovered_rx.recv()) + .await + .expect("heartbeat did not observe the recovered gateway") + .expect("heartbeat stopped before observing the recovered gateway"); + assert!(!heartbeat.is_finished()); + assert_eq!(restart_calls.load(Ordering::SeqCst), 1); + assert!( + observed_urls + .lock() + .unwrap() + .iter() + .any(|url| url == "http://recovered-gateway") + ); + heartbeat.abort(); + assert!(heartbeat.await.unwrap_err().is_cancelled()); +} + +#[tokio::test] +async fn heartbeat_ignores_isolated_transient_health_failures() { + let health_calls = Arc::new(AtomicUsize::new(0)); + let restart_calls = Arc::new(AtomicUsize::new(0)); + let (observed_tx, mut observed_rx) = tokio::sync::mpsc::unbounded_channel(); + let heartbeat = tokio::spawn(maintain_gateway_with( + "127.0.0.1:47632".parse().unwrap(), + "http://gateway".into(), + Duration::from_millis(1), + { + let health_calls = health_calls.clone(); + move |_url| { + let observed_tx = observed_tx.clone(); + let call = health_calls.fetch_add(1, Ordering::SeqCst) + 1; + async move { + if call == 9 { + let _ = observed_tx.send(()); + } + Ok(call.is_multiple_of(3)) + } + } + }, + { + let restart_calls = restart_calls.clone(); + move |address, _expected_instance| { + restart_calls.fetch_add(1, Ordering::SeqCst); + async move { + Ok(crate::bootstrap::GatewayEndpoint { + address, + url: "http://gateway".into(), + instance_id: "gateway".into(), + }) + } + } + }, + )); + + tokio::time::timeout(Duration::from_secs(5), observed_rx.recv()) + .await + .expect("heartbeat did not complete three transient-failure cycles") + .expect("heartbeat stopped before completing three transient-failure cycles"); + assert!(!heartbeat.is_finished()); + assert!(health_calls.load(Ordering::SeqCst) >= 9); + assert_eq!(restart_calls.load(Ordering::SeqCst), 0); + heartbeat.abort(); + assert!(heartbeat.await.unwrap_err().is_cancelled()); +} + +#[tokio::test] +async fn heartbeat_rediscovery_consumes_the_shared_restart_allowance() { + let restart_calls = Arc::new(AtomicUsize::new(0)); + let error = maintain_gateway_with( + "127.0.0.1:47632".parse().unwrap(), + "http://gateway".into(), + Duration::from_millis(1), + |_url| async { Ok(false) }, + { + let restart_calls = restart_calls.clone(); + move |address, _expected_instance| { + let restart_calls = restart_calls.clone(); + async move { + let attempt = restart_calls.fetch_add(1, Ordering::SeqCst); + Ok(crate::bootstrap::GatewayEndpoint { + address, + url: "http://gateway".into(), + instance_id: format!("gateway-{attempt}"), + }) + } + } + }, + ) + .await + .unwrap_err(); + + assert_eq!(restart_calls.load(Ordering::SeqCst), 1); + assert!(error.to_string().contains("after its coordinated restart")); +} + +#[tokio::test] +async fn heartbeat_exits_with_the_restart_failure() { + let error = maintain_gateway_with( + "127.0.0.1:47632".parse().unwrap(), + "http://dead-gateway".into(), + Duration::from_millis(1), + |_url| async { Ok(false) }, + |_bind, _expected_instance| async { + Err(CliError::Launch("coordinated restart failed".into())) + }, + ) + .await + .unwrap_err(); + + assert!(error.to_string().contains("coordinated restart failed")); +} + +#[tokio::test] +async fn heartbeat_attempts_at_most_one_successful_restart() { + let restart_calls = Arc::new(AtomicUsize::new(0)); + let error = maintain_gateway_with( + "127.0.0.1:47632".parse().unwrap(), + "http://dead-gateway".into(), + Duration::from_millis(1), + |_url| async { Ok(false) }, + { + let restart_calls = restart_calls.clone(); + move |address, _expected_instance| { + let restart_calls = restart_calls.clone(); + async move { + restart_calls.fetch_add(1, Ordering::SeqCst); + Ok(crate::bootstrap::GatewayEndpoint { + address, + url: "http://still-unhealthy".into(), + instance_id: "still-unhealthy".into(), + }) + } + } + }, + ) + .await + .unwrap_err(); + + assert_eq!(restart_calls.load(Ordering::SeqCst), 1); + assert!(error.to_string().contains("after its coordinated restart")); +} + +#[tokio::test] +async fn old_mcp_maintenance_loop_exits_when_install_generation_is_replaced() { + let dir = tempfile::tempdir().unwrap(); + let plugin_root = dir.path().join("plugin"); + let generation_path = plugin_root.join(GENERATION_FILE_NAME); + let generation_lock = dir.path().join("generation-transaction.lock"); + crate::installation::generation::write_new_generation_with_token_at( + &generation_path, + &generation_lock, + ) + .unwrap(); + let generation = InstallGeneration::capture(generation_path.clone()).unwrap(); + let health_calls = Arc::new(AtomicUsize::new(0)); + let restart_calls = Arc::new(AtomicUsize::new(0)); + let (observed_tx, observed_rx) = tokio::sync::oneshot::channel(); + let observed_tx = Arc::new(Mutex::new(Some(observed_tx))); + let heartbeat = tokio::spawn(maintain_gateway_with_generation( + "127.0.0.1:47632".parse().unwrap(), + "http://old-gateway".into(), + Duration::from_millis(100), + { + let health_calls = health_calls.clone(); + move |_url| { + health_calls.fetch_add(1, Ordering::SeqCst); + let observed_tx = observed_tx.clone(); + async move { + if let Some(sender) = observed_tx.lock().unwrap().take() { + let _ = sender.send(()); + } + Ok(false) + } + } + }, + { + let restart_calls = restart_calls.clone(); + move |address, _expected_instance| { + restart_calls.fetch_add(1, Ordering::SeqCst); + async move { + Ok(crate::bootstrap::GatewayEndpoint { + address, + url: "http://unexpected-restart".into(), + instance_id: "unexpected".into(), + }) + } + } + }, + move || { + let generation = generation.clone(); + async move { generation.verify_current().map_err(CliError::Launch) } + }, + )); + + tokio::time::timeout(Duration::from_secs(5), observed_rx) + .await + .expect("old MCP maintenance loop did not perform its first health check") + .expect("old MCP maintenance loop stopped before its first health check"); + let mut retirement = GenerationRetirement::acquire(&generation_path) + .unwrap() + .expect("installed generation should be retired"); + retirement.invalidate_for_replacement().unwrap(); + // Force installation swaps the whole plugin tree while retaining its external transaction + // lock until the replacement is committed. + std::fs::rename(&plugin_root, dir.path().join("retired-plugin")).unwrap(); + crate::installation::generation::write_staged_generation_with_token( + &generation_path, + &generation_lock, + ) + .unwrap(); + retirement.commit_replacement(); + drop(retirement); + + let error = tokio::time::timeout(Duration::from_secs(5), heartbeat) + .await + .expect("old MCP maintenance loop did not observe generation replacement") + .unwrap() + .unwrap_err(); + assert!(error.to_string().contains("has been retired")); + assert_eq!(health_calls.load(Ordering::SeqCst), 1); + assert_eq!(restart_calls.load(Ordering::SeqCst), 0); +} + +#[test] +fn invalidated_install_generation_can_be_restored_before_rollback_registration() { + let dir = tempfile::tempdir().unwrap(); + let generation_path = dir.path().join(GENERATION_FILE_NAME); + write_new_generation(&generation_path).unwrap(); + let original = InstallGeneration::capture(generation_path.clone()).unwrap(); + let mut retirement = GenerationRetirement::acquire(&generation_path) + .unwrap() + .expect("installed generation should be retired"); + + retirement.invalidate_for_replacement().unwrap(); + let (result_tx, result_rx) = std::sync::mpsc::channel(); + let verifier = std::thread::spawn(move || result_tx.send(original.verify_current()).unwrap()); + assert!( + result_rx.recv_timeout(Duration::from_millis(50)).is_err(), + "MCP lifecycle verification observed an uncommitted retirement" + ); + + retirement.restore_after_rollback().unwrap(); + result_rx + .recv_timeout(Duration::from_secs(1)) + .unwrap() + .unwrap(); + verifier.join().unwrap(); + InstallGeneration::capture(generation_path).unwrap(); +} + +#[test] +fn retired_install_generation_remains_retryable_but_not_adoptable() { + let dir = tempfile::tempdir().unwrap(); + let generation_path = dir.path().join(GENERATION_FILE_NAME); + write_new_generation(&generation_path).unwrap(); + let mut retirement = GenerationRetirement::acquire(&generation_path) + .unwrap() + .expect("installed generation should be retired"); + retirement.invalidate_for_replacement().unwrap(); + retirement.commit_replacement(); + drop(retirement); + + let mut resumed = GenerationRetirement::acquire(&generation_path) + .unwrap() + .expect("a retired generation should support cleanup retry"); + resumed.invalidate_for_replacement().unwrap(); + resumed.commit_replacement(); + drop(resumed); + + let error = InstallGeneration::capture(generation_path).unwrap_err(); + assert!(error.contains("has been retired"), "{error}"); +} + +#[test] +fn default_mcp_gateway_uses_plugin_provider_port() { + assert_eq!(default_mcp_bind().to_string(), "127.0.0.1:47632"); +} + +#[test] +fn persistent_mcp_server_contract_is_host_neutral_and_generation_fenced() { + let server = persistent_server( + std::path::Path::new("/opt/nemo relay/bin/nemo-relay"), + std::path::Path::new("/tmp/plugin/.nemo-relay-generation"), + "generation-token", + ); + + assert_eq!(server["command"], "/opt/nemo relay/bin/nemo-relay"); + assert_eq!(server["args"], json!(["mcp"])); + assert_eq!( + server["env"]["NEMO_RELAY_GATEWAY_BIND"], + crate::bootstrap::DEFAULT_BIND + ); + assert_eq!( + server["env"]["NEMO_RELAY_MCP_GENERATION_FILE"], + "/tmp/plugin/.nemo-relay-generation" + ); + assert_eq!( + server["env"]["NEMO_RELAY_MCP_GENERATION"], + "generation-token" + ); +} diff --git a/crates/cli/tests/coverage/plugins_lifecycle_target_tests.rs b/crates/cli/tests/coverage/shared/plugins_lifecycle_target_tests.rs similarity index 100% rename from crates/cli/tests/coverage/plugins_lifecycle_target_tests.rs rename to crates/cli/tests/coverage/shared/plugins_lifecycle_target_tests.rs diff --git a/crates/cli/tests/coverage/plugins_lifecycle_tests.rs b/crates/cli/tests/coverage/shared/plugins_lifecycle_tests.rs similarity index 65% rename from crates/cli/tests/coverage/plugins_lifecycle_tests.rs rename to crates/cli/tests/coverage/shared/plugins_lifecycle_tests.rs index 2781cbc00..d0ec546cb 100644 --- a/crates/cli/tests/coverage/plugins_lifecycle_tests.rs +++ b/crates/cli/tests/coverage/shared/plugins_lifecycle_tests.rs @@ -8,17 +8,99 @@ use std::{ }; use super::*; -use crate::config::{ - PluginsAddCommand, PluginsDisableCommand, PluginsEnableCommand, PluginsInspectCommand, - PluginsListCommand, PluginsRemoveCommand, PluginsScopeArgs, PluginsValidateCommand, ServerArgs, -}; use crate::error::PluginLifecycleFailureKind; +use crate::plugins::{ + ConfigurationScope, PluginsAddRequest, PluginsDisableRequest, PluginsEnableRequest, + PluginsInspectRequest, PluginsListRequest, PluginsRemoveRequest, PluginsValidateRequest, +}; +use crate::server::GatewayOverrides; use base64::Engine; -use nemo_relay::plugin::dynamic::DynamicPluginFailurePhase; +use nemo_relay::plugin::dynamic::{ + DynamicPluginFailurePhase, WorkerPluginLoadSpec, load_worker_plugins, +}; use ring::rand::SystemRandom; use ring::signature::{Ed25519KeyPair, KeyPair}; use sha2::{Digest, Sha256}; +#[cfg(unix)] +#[test] +fn python_venv_launcher_detection_only_preserves_bin_python_links() { + assert!(is_python_venv_launcher(Path::new("env/bin/python"))); + assert!(is_python_venv_launcher(Path::new("env/bin/python3.11"))); + assert!(!is_python_venv_launcher(Path::new("env/bin/pip"))); + assert!(!is_python_venv_launcher(Path::new("env/lib/python3.11"))); +} + +#[cfg(unix)] +#[test] +fn snapshot_protection_does_not_follow_python_launcher_symlink() { + use std::os::unix::fs::{PermissionsExt, symlink}; + + let temp = tempfile::tempdir().unwrap(); + let target = temp.path().join("external-python"); + std::fs::write(&target, b"python").unwrap(); + std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o755)).unwrap(); + let root = temp.path().join("snapshot"); + let bin = root.join("bin"); + std::fs::create_dir_all(&bin).unwrap(); + symlink(&target, bin.join("python")).unwrap(); + + protect_snapshot_tree(&root).unwrap(); + + assert!( + std::fs::symlink_metadata(bin.join("python")) + .unwrap() + .file_type() + .is_symlink() + ); + assert_eq!( + std::fs::metadata(&target).unwrap().permissions().mode() & 0o777, + 0o755 + ); + make_snapshot_removable(&root); +} + +#[cfg(unix)] +#[test] +fn snapshot_digest_hashes_python_launcher_symlink_without_following_it() { + use std::os::unix::fs::symlink; + + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("snapshot"); + let bin = root + .join(MANAGED_ENVIRONMENTS_DIR) + .join("environment") + .join("bin"); + std::fs::create_dir_all(&bin).unwrap(); + let launcher = bin.join("python"); + symlink("/missing/python-a", &launcher).unwrap(); + + let first_verification = snapshot_tree_digest(&root, false).unwrap(); + let first_identity = snapshot_tree_digest(&root, true).unwrap(); + + std::fs::remove_file(&launcher).unwrap(); + std::fs::write(&launcher, b"/missing/python-a").unwrap(); + assert_ne!( + first_verification, + snapshot_tree_digest(&root, false).unwrap(), + "a regular file must not collide with an equivalent symlink target" + ); + + std::fs::remove_file(&launcher).unwrap(); + symlink("/missing/python-b", &launcher).unwrap(); + + assert_ne!( + first_verification, + snapshot_tree_digest(&root, false).unwrap(), + "verification must include the exact launcher target" + ); + assert_eq!( + first_identity, + snapshot_tree_digest(&root, true).unwrap(), + "managed environment contents are excluded from stable gateway identity" + ); +} + struct CurrentDirGuard { original: PathBuf, } @@ -443,152 +525,1224 @@ fn trust_evaluation_short_circuits_when_policy_is_blocked() { assert!(trust.failure().is_none()); } -fn write_native_dynamic_manifest(dir: &Path, plugin_id: &str) -> PathBuf { - let artifact_body = b"native plugin fixture"; - std::fs::write(dir.join("libfixture_native.so"), artifact_body).unwrap(); - let digest = format!( - "sha256:{}", - Sha256::digest(artifact_body) - .iter() - .map(|byte| format!("{byte:02x}")) - .collect::() +fn write_native_dynamic_manifest(dir: &Path, plugin_id: &str) -> PathBuf { + let artifact_body = b"native plugin fixture"; + std::fs::write(dir.join("libfixture_native.so"), artifact_body).unwrap(); + let digest = format!( + "sha256:{}", + Sha256::digest(artifact_body) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::() + ); + let manifest_path = dir.join("relay-plugin.toml"); + std::fs::write( + &manifest_path, + format!( + r#" +manifest_version = 1 + +[plugin] +id = "{plugin_id}" +kind = "rust_dynamic" + +[compat] +relay = "0.5" +native_api = "1" + +[defaults] +enabled = false + +[capabilities] +items = ["plugin_native"] + +[source] +artifact = "libfixture_native.so" + +[integrity] +sha256 = "{digest}" + +[load] +library = "libfixture_native.so" +symbol = "nemo_relay_fixture_native_plugin" +"#, + digest = digest, + ), + ) + .unwrap(); + manifest_path +} + +fn materialize_native_example_manifest(dir: &Path) -> (PathBuf, PathBuf) { + let artifact_name = format!( + "{}nemo_relay_rust_native_plugin_example{}", + std::env::consts::DLL_PREFIX, + std::env::consts::DLL_SUFFIX + ); + let artifact_relative = Path::new("target").join("debug").join(&artifact_name); + let artifact_path = dir.join(&artifact_relative); + std::fs::create_dir_all(artifact_path.parent().unwrap()).unwrap(); + let artifact_body = b"native plugin example fixture"; + std::fs::write(&artifact_path, artifact_body).unwrap(); + let digest = Sha256::digest(artifact_body) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::(); + + let repository_root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + let template = std::fs::read_to_string( + repository_root.join("examples/rust-native-plugin/relay-plugin.toml"), + ) + .unwrap(); + let config_schema = + std::fs::read(repository_root.join("examples/rust-native-plugin/config.schema.json")) + .unwrap(); + let manifest = template + .replace("", &artifact_name) + .replace("", &digest); + let manifest_path = dir.join("relay-plugin.toml"); + std::fs::write(&manifest_path, manifest).unwrap(); + std::fs::write(dir.join("config.schema.json"), config_schema).unwrap(); + (manifest_path, artifact_path) +} + +#[test] +fn tracked_native_plugin_example_satisfies_default_trust_policy() { + let temp = tempfile::tempdir().unwrap(); + let _env = EnvScope::hermetic(&temp); + let _cwd = CurrentDirGuard::enter(temp.path()); + let plugin_dir = temp.path().join("plugins").join("native-example"); + std::fs::create_dir_all(&plugin_dir).unwrap(); + materialize_native_example_manifest(&plugin_dir); + + add( + PluginsAddRequest { + scope: ConfigurationScope::Project, + path: plugin_dir, + }, + &GatewayOverrides::default(), + ) + .unwrap(); + + let resolved = resolve_plugins_config(None).unwrap(); + assert_eq!(resolved.dynamic_plugins.len(), 1); + assert_eq!( + resolved.dynamic_plugins[0].plugin_id, + "examples.rust_native_policy" + ); +} + +#[test] +fn tracked_native_plugin_example_rejects_tampered_artifact() { + let temp = tempfile::tempdir().unwrap(); + let _env = EnvScope::hermetic(&temp); + let _cwd = CurrentDirGuard::enter(temp.path()); + let plugin_dir = temp.path().join("plugins").join("native-example"); + std::fs::create_dir_all(&plugin_dir).unwrap(); + let (_, artifact_path) = materialize_native_example_manifest(&plugin_dir); + std::fs::write(artifact_path, b"tampered native plugin example fixture").unwrap(); + + let error = add( + PluginsAddRequest { + scope: ConfigurationScope::Project, + path: plugin_dir, + }, + &GatewayOverrides::default(), + ) + .unwrap_err(); + + match error { + CliError::PluginLifecycle { + kind: PluginLifecycleFailureKind::Refused, + code: Some("integrity_failed"), + message, + .. + } => assert!(message.contains("failed integrity verification")), + other => panic!("unexpected integrity add error: {other}"), + } + assert!( + resolve_plugins_config(None) + .unwrap() + .dynamic_plugins + .is_empty() + ); +} + +#[cfg(unix)] +#[test] +fn activation_snapshot_never_rereads_replaced_or_oversized_worker_code() { + use std::os::unix::fs::PermissionsExt; + + let temp = tempfile::tempdir().unwrap(); + let plugin_dir = temp.path().join("plugin"); + let worker_dir = temp.path().join("worker-runtime"); + std::fs::create_dir_all(&plugin_dir).unwrap(); + std::fs::create_dir_all(&worker_dir).unwrap(); + let artifact_path = worker_dir.join("worker.sh"); + let safe_worker = "#!/bin/sh\nexit 1\n".to_string(); + std::fs::write(&artifact_path, &safe_worker).unwrap(); + std::fs::write(worker_dir.join("resource.txt"), b"expected\n").unwrap(); + std::fs::set_permissions(&artifact_path, std::fs::Permissions::from_mode(0o755)).unwrap(); + let digest = Sha256::digest(safe_worker.as_bytes()) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::(); + let manifest_path = plugin_dir.join("relay-plugin.toml"); + std::fs::write( + &manifest_path, + format!( + r#"manifest_version = 1 + +[plugin] +id = "acme.snapshot-race" +kind = "worker" + +[compat] +relay = "0.5" +worker_protocol = "grpc-v1" + +[defaults] +enabled = false + +[capabilities] +items = ["plugin_worker"] + +[source] +artifact = "../worker-runtime/worker.sh" + +[integrity] +sha256 = "sha256:{digest}" + +[load] +runtime = "command" +entrypoint = "../worker-runtime/worker.sh" +"# + ), + ) + .unwrap(); + let snapshot = DynamicPluginActivationSnapshot::create( + manifest_path.to_string_lossy().as_ref(), + "acme.snapshot-race", + DynamicPluginKind::Worker, + None, + &crate::plugins::policy::DynamicPluginHostPolicy::default(), + ) + .unwrap(); + assert_eq!( + std::fs::read(snapshot.root.join("external-entrypoint/resource.txt")).unwrap(), + b"expected\n" + ); + let (activation_manifest, _) = + DynamicPluginManifest::load_from_path(PathBuf::from(snapshot.activation_manifest_ref())) + .unwrap(); + let activation_artifact = activation_manifest + .source + .as_ref() + .and_then(|source| source.artifact.as_deref()) + .unwrap(); + let DynamicPluginManifestLoad::Worker(activation_load) = &activation_manifest.load else { + panic!("command activation must retain a worker load contract"); + }; + assert_eq!( + Some(activation_artifact), + activation_load.entrypoint.as_deref(), + "the integrity-checked artifact and executed entrypoint must be one snapshot file" + ); + let source_closure_digest = + dynamic_plugin_runtime_closure_digest(manifest_path.to_string_lossy().as_ref(), None) + .unwrap(); + assert_eq!(snapshot.closure_digest(), source_closure_digest); + + let marker = temp.path().join("replaced-worker-executed"); + std::fs::write( + &artifact_path, + format!("#!/bin/sh\ntouch {}\nexit 1\n", marker.display()), + ) + .unwrap(); + std::fs::OpenOptions::new() + .write(true) + .open(&artifact_path) + .unwrap() + .set_len(crate::filesystem::bounded::MAX_BOUNDED_FILE_BYTES + 1) + .unwrap(); + std::fs::set_permissions(&artifact_path, std::fs::Permissions::from_mode(0o755)).unwrap(); + std::fs::write(&manifest_path, b"not valid TOML").unwrap(); + std::fs::OpenOptions::new() + .write(true) + .open(&manifest_path) + .unwrap() + .set_len(crate::filesystem::bounded::MAX_BOUNDED_FILE_BYTES + 1) + .unwrap(); + + let error = match load_worker_plugins(vec![WorkerPluginLoadSpec { + plugin_id: "acme.snapshot-race".into(), + manifest_ref: snapshot.activation_manifest_ref(), + environment_ref: None, + config: Map::new(), + }]) { + Ok(_) => panic!("safe snapshot worker unexpectedly activated"), + Err(error) => error.to_string(), + }; + + assert!( + !marker.exists(), + "the replaced original worker was executed" + ); + assert!(!error.contains("invalid relay-plugin.toml"), "{error}"); + assert!(!error.contains("exceeds the"), "{error}"); +} + +#[cfg(unix)] +#[test] +fn activation_snapshot_detects_mutation_of_the_runtime_copy() { + use std::os::unix::fs::PermissionsExt; + + let temp = tempfile::tempdir().unwrap(); + let manifest_path = write_native_dynamic_manifest(temp.path(), "acme.snapshot-mutation"); + let snapshot = DynamicPluginActivationSnapshot::create( + manifest_path.to_string_lossy().as_ref(), + "acme.snapshot-mutation", + DynamicPluginKind::RustDynamic, + None, + &crate::plugins::policy::DynamicPluginHostPolicy::default(), + ) + .unwrap(); + std::fs::set_permissions(&snapshot.root, std::fs::Permissions::from_mode(0o700)).unwrap(); + std::fs::set_permissions( + snapshot.activation_manifest.parent().unwrap(), + std::fs::Permissions::from_mode(0o700), + ) + .unwrap(); + std::fs::set_permissions( + &snapshot.activation_manifest, + std::fs::Permissions::from_mode(0o600), + ) + .unwrap(); + std::fs::write(&snapshot.activation_manifest, b"replaced").unwrap(); + + let error = snapshot.verify_current().unwrap_err().to_string(); + assert!(error.contains("changed before code load"), "{error}"); +} + +#[test] +fn activation_snapshot_keeps_adjacent_native_dependencies_for_external_load_target() { + let temp = tempfile::tempdir().unwrap(); + let manifest_dir = temp.path().join("plugin"); + let native_dir = temp.path().join("native-runtime"); + std::fs::create_dir_all(&manifest_dir).unwrap(); + std::fs::create_dir_all(&native_dir).unwrap(); + let library = native_dir.join("libfixture_native.so"); + let library_bytes = b"native plugin fixture"; + std::fs::write(&library, library_bytes).unwrap(); + std::fs::write( + native_dir.join("libadjacent_dependency.so"), + b"adjacent dependency", + ) + .unwrap(); + let digest = Sha256::digest(library_bytes) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::(); + let manifest_path = manifest_dir.join("relay-plugin.toml"); + std::fs::write( + &manifest_path, + format!( + r#"manifest_version = 1 + +[plugin] +id = "acme.external-native-closure" +kind = "rust_dynamic" + +[compat] +relay = "0.5" +native_api = "1" + +[defaults] +enabled = false + +[capabilities] +items = ["plugin_native"] + +[source] +artifact = "../native-runtime/libfixture_native.so" + +[integrity] +sha256 = "sha256:{digest}" + +[load] +library = "../native-runtime/libfixture_native.so" +symbol = "nemo_relay_fixture_native_plugin" +"#, + ), + ) + .unwrap(); + + let snapshot = DynamicPluginActivationSnapshot::create( + manifest_path.to_string_lossy().as_ref(), + "acme.external-native-closure", + DynamicPluginKind::RustDynamic, + None, + &crate::plugins::policy::DynamicPluginHostPolicy::default(), + ) + .unwrap(); + + assert!( + snapshot + .root + .join("external-library/libadjacent_dependency.so") + .is_file() + ); + assert_eq!( + snapshot.closure_digest(), + dynamic_plugin_runtime_closure_digest(manifest_path.to_string_lossy().as_ref(), None) + .unwrap() + ); +} + +#[test] +fn activation_snapshot_and_python_attestation_enforce_exact_directory_depth_boundary() { + let temp = tempfile::tempdir().unwrap(); + let _env = EnvScope::hermetic(&temp); + let plugin_dir = temp.path().join("deep-plugin"); + std::fs::create_dir_all(&plugin_dir).unwrap(); + let manifest_path = write_native_dynamic_manifest(&plugin_dir, "acme.deep-closure"); + let mut deep_plugin_path = plugin_dir.clone(); + for _ in 1..MAX_SNAPSHOT_DEPTH { + deep_plugin_path.push("d"); + } + std::fs::create_dir_all(&deep_plugin_path).unwrap(); + + dynamic_plugin_runtime_closure_digest(manifest_path.to_string_lossy().as_ref(), None).unwrap(); + DynamicPluginActivationSnapshot::create( + manifest_path.to_string_lossy().as_ref(), + "acme.deep-closure", + DynamicPluginKind::RustDynamic, + None, + &crate::plugins::policy::DynamicPluginHostPolicy::default(), + ) + .unwrap(); + + deep_plugin_path.push("too-deep"); + std::fs::create_dir(&deep_plugin_path).unwrap(); + + let error = + dynamic_plugin_runtime_closure_digest(manifest_path.to_string_lossy().as_ref(), None) + .unwrap_err() + .to_string(); + assert!(error.contains("traversal depth"), "{error}"); + + let error = DynamicPluginActivationSnapshot::create( + manifest_path.to_string_lossy().as_ref(), + "acme.deep-closure", + DynamicPluginKind::RustDynamic, + None, + &crate::plugins::policy::DynamicPluginHostPolicy::default(), + ) + .unwrap_err() + .to_string(); + assert!(error.contains("traversal depth"), "{error}"); + + let environment_path = temp.path().join("deep-environment"); + let mut deep_environment_path = environment_path.clone(); + for _ in 1..environment::MAX_ENVIRONMENT_DEPTH { + deep_environment_path.push("d"); + } + std::fs::create_dir_all(&deep_environment_path).unwrap(); + environment::write_environment_attestation(&environment_path, "sha256:fixture-source-artifact") + .unwrap(); + + deep_environment_path.push("too-deep"); + std::fs::create_dir(&deep_environment_path).unwrap(); + let error = environment::write_environment_attestation( + &environment_path, + "sha256:fixture-source-artifact", + ) + .unwrap_err(); + assert!(error.contains("traversal depth"), "{error}"); +} + +#[test] +fn runtime_directory_collection_rejects_before_exceeding_bounded_sort_capacity() { + let temp = tempfile::tempdir().unwrap(); + for name in ["one", "two", "three"] { + std::fs::write(temp.path().join(name), name).unwrap(); + } + + let error = bounded_runtime_directory_entries(temp.path(), 2) + .unwrap_err() + .to_string(); + + assert!( + error.contains("entry activation snapshot budget"), + "{error}" + ); +} + +#[test] +fn activation_snapshot_budgets_reject_entry_and_byte_overflow() { + let path = Path::new("fixture"); + let mut entry_budget = SnapshotBudget::default(); + let entry_error = entry_budget + .record_entries(path, MAX_SNAPSHOT_FILES + 1) + .unwrap_err() + .to_string(); + assert!( + entry_error.contains("entry activation snapshot budget"), + "{entry_error}" + ); + + let mut byte_budget = SnapshotBudget::default(); + let byte_error = byte_budget + .record_bytes( + path, + usize::try_from(crate::filesystem::bounded::MAX_BOUNDED_FILE_BYTES).unwrap() + 1, + ) + .unwrap_err() + .to_string(); + assert!( + byte_error.contains("byte activation snapshot budget"), + "{byte_error}" + ); + + let mut closure = RuntimeClosureSources { + entries: MAX_SNAPSHOT_FILES, + ..RuntimeClosureSources::default() + }; + let closure_error = closure.record_entry().unwrap_err().to_string(); + assert!( + closure_error.contains("entry activation snapshot budget"), + "{closure_error}" + ); +} + +#[test] +fn activation_snapshot_rejects_identity_mismatch_missing_files_and_policy_denial() { + let temp = tempfile::tempdir().unwrap(); + let plugin_dir = temp.path().join("plugin"); + std::fs::create_dir_all(&plugin_dir).unwrap(); + let manifest = write_dynamic_manifest(&plugin_dir, "acme.snapshot-contracts"); + + let identity_error = DynamicPluginActivationSnapshot::create( + manifest.to_string_lossy().as_ref(), + "acme.other-plugin", + DynamicPluginKind::Worker, + None, + &crate::plugins::policy::DynamicPluginHostPolicy::default(), + ) + .unwrap_err() + .to_string(); + assert!( + identity_error.contains("identity changed"), + "{identity_error}" + ); + + let contents = std::fs::read_to_string(&manifest).unwrap(); + std::fs::write( + &manifest, + contents.replace("entrypoint = \"plugin.py\"", "entrypoint = \"other.py\""), + ) + .unwrap(); + std::fs::write(plugin_dir.join("other.py"), b"print('other')\n").unwrap(); + let entrypoint_error = DynamicPluginActivationSnapshot::create( + manifest.to_string_lossy().as_ref(), + "acme.snapshot-contracts", + DynamicPluginKind::Worker, + None, + &crate::plugins::policy::DynamicPluginHostPolicy::default(), + ) + .unwrap_err() + .to_string(); + assert!( + entrypoint_error.contains("integrity-checked source.artifact"), + "{entrypoint_error}" + ); + let closure_error = + dynamic_plugin_runtime_closure_digest(manifest.to_string_lossy().as_ref(), None) + .unwrap_err() + .to_string(); + assert!( + closure_error.contains("integrity-checked source.artifact"), + "{closure_error}" + ); + + std::fs::write(&manifest, contents).unwrap(); + let blocked = crate::plugins::policy::DynamicPluginHostPolicy { + defaults: crate::plugins::policy::DynamicPluginHostPolicyEffect { + allowed: Some(false), + ..crate::plugins::policy::DynamicPluginHostPolicyEffect::default() + }, + ..crate::plugins::policy::DynamicPluginHostPolicy::default() + }; + let policy_error = DynamicPluginActivationSnapshot::create( + manifest.to_string_lossy().as_ref(), + "acme.snapshot-contracts", + DynamicPluginKind::Worker, + None, + &blocked, + ) + .unwrap_err() + .to_string(); + assert!( + policy_error.contains("violates host policy"), + "{policy_error}" + ); + + std::fs::remove_file(plugin_dir.join("plugin.py")).unwrap(); + let missing_error = DynamicPluginActivationSnapshot::create( + manifest.to_string_lossy().as_ref(), + "acme.snapshot-contracts", + DynamicPluginKind::Worker, + None, + &crate::plugins::policy::DynamicPluginHostPolicy::default(), + ) + .unwrap_err() + .to_string(); + assert!( + missing_error.contains("failed to normalize"), + "{missing_error}" + ); +} + +#[test] +fn activation_snapshot_copies_declared_signature_into_stable_identity() { + let temp = tempfile::tempdir().unwrap(); + let plugin_dir = temp.path().join("plugin"); + std::fs::create_dir_all(&plugin_dir).unwrap(); + let manifest = write_dynamic_manifest_with_options( + &plugin_dir, + "acme.signed-snapshot", + &["plugin_worker"], + Some("plugin.py.sig"), + ); + std::fs::write(plugin_dir.join("plugin.py.sig"), b"fixture signature\n").unwrap(); + + let snapshot = DynamicPluginActivationSnapshot::create( + manifest.to_string_lossy().as_ref(), + "acme.signed-snapshot", + DynamicPluginKind::Worker, + None, + &crate::plugins::policy::DynamicPluginHostPolicy::default(), + ) + .unwrap(); + + let signature_logical = snapshot + .identity_files + .keys() + .find(|path| path.ends_with("plugin.py.sig")) + .cloned() + .expect("signature is part of the stable snapshot identity"); + assert!(snapshot.identity_file(&signature_logical).is_some()); + assert_eq!( + snapshot.closure_digest(), + dynamic_plugin_runtime_closure_digest(manifest.to_string_lossy().as_ref(), None).unwrap() + ); +} + +#[test] +fn python_snapshot_contract_requires_environment_and_trusted_source_digest() { + let temp = tempfile::tempdir().unwrap(); + let plugin_dir = temp.path().join("plugin"); + std::fs::create_dir_all(&plugin_dir).unwrap(); + let manifest = write_python_dynamic_manifest(&plugin_dir, "acme.python-contract"); + + let snapshot_error = DynamicPluginActivationSnapshot::create( + manifest.to_string_lossy().as_ref(), + "acme.python-contract", + DynamicPluginKind::Worker, + None, + &crate::plugins::policy::DynamicPluginHostPolicy::default(), + ) + .unwrap_err() + .to_string(); + assert!( + snapshot_error.contains("no managed environment"), + "{snapshot_error}" + ); + let closure_error = + dynamic_plugin_runtime_closure_digest(manifest.to_string_lossy().as_ref(), None) + .unwrap_err() + .to_string(); + assert!( + closure_error.contains("no managed environment"), + "{closure_error}" + ); + + let contents = std::fs::read_to_string(&manifest).unwrap(); + let integrity = contents + .find("[integrity]") + .expect("fixture has integrity section"); + let load = contents.find("[load]").expect("fixture has load section"); + std::fs::write( + &manifest, + format!("{}{}", &contents[..integrity], &contents[load..]), + ) + .unwrap(); + let digest_error = dynamic_plugin_runtime_closure_digest( + manifest.to_string_lossy().as_ref(), + Some(temp.path().join("environment").to_string_lossy().as_ref()), + ) + .unwrap_err() + .to_string(); + assert!( + digest_error.contains("requires integrity.sha256"), + "{digest_error}" + ); +} + +#[cfg(unix)] +#[test] +fn snapshot_directory_copy_preserves_python_launcher_and_rejects_special_entries() { + use std::ffi::CString; + use std::os::unix::fs::symlink; + + let temp = tempfile::tempdir().unwrap(); + let source = temp.path().join("environment"); + let bin = source.join("bin"); + std::fs::create_dir_all(&bin).unwrap(); + let interpreter = temp.path().join("managed-python"); + std::fs::write(&interpreter, b"python").unwrap(); + symlink(&interpreter, bin.join("python3.11")).unwrap(); + let destination = temp.path().join("snapshot"); + let mut copied = HashMap::new(); + let mut budget = SnapshotBudget::default(); + + copy_snapshot_directory( + &source, + &destination, + &mut copied, + &mut budget, + false, + &mut Vec::new(), + ) + .unwrap(); + + assert!( + std::fs::symlink_metadata(destination.join("bin/python3.11")) + .unwrap() + .file_type() + .is_symlink() + ); + assert!(!is_python_venv_launcher(Path::new("/"))); + + let fifo_source = temp.path().join("fifo-source"); + std::fs::create_dir(&fifo_source).unwrap(); + let fifo = fifo_source.join("worker.pipe"); + let fifo_c = CString::new(fifo.as_os_str().as_encoded_bytes()).unwrap(); + // SAFETY: `fifo_c` is a valid NUL-terminated path and the mode contains only permission bits. + assert_eq!(unsafe { libc::mkfifo(fifo_c.as_ptr(), 0o600) }, 0); + let special_error = copy_snapshot_directory( + &fifo_source, + &temp.path().join("fifo-snapshot"), + &mut HashMap::new(), + &mut SnapshotBudget::default(), + false, + &mut Vec::new(), + ) + .unwrap_err() + .to_string(); + assert!( + special_error.contains("regular file or directory"), + "{special_error}" + ); + + let regular = temp.path().join("regular"); + std::fs::write(®ular, b"regular").unwrap(); + let destination_directory = temp.path().join("destination-directory"); + std::fs::create_dir(&destination_directory).unwrap(); + let write_error = copy_snapshot_regular_file( + ®ular, + &destination_directory, + &mut HashMap::new(), + &mut SnapshotBudget::default(), + "fixture", + ) + .unwrap_err() + .to_string(); + assert!(write_error.contains("failed to write dynamic plugin snapshot file")); +} + +#[cfg(unix)] +#[test] +fn snapshot_directory_walk_rejects_missing_cycles_dangling_links_and_depth() { + use std::os::unix::fs::symlink; + + let temp = tempfile::tempdir().unwrap(); + let missing = temp.path().join("missing"); + let normalization_error = copy_snapshot_directory( + &missing, + &temp.path().join("destination"), + &mut HashMap::new(), + &mut SnapshotBudget::default(), + false, + &mut Vec::new(), + ) + .unwrap_err() + .to_string(); + assert!( + normalization_error.contains("failed to normalize"), + "{normalization_error}" + ); + + let source = temp.path().join("source"); + std::fs::create_dir(&source).unwrap(); + let canonical = source.canonicalize().unwrap(); + let cycle_error = copy_snapshot_directory_contents( + &source, + &temp.path().join("cycle-destination"), + &mut HashMap::new(), + &mut SnapshotBudget::default(), + false, + &mut vec![canonical.clone()], + ) + .unwrap_err() + .to_string(); + assert!(cycle_error.contains("symlink cycle"), "{cycle_error}"); + + let destination_file = temp.path().join("destination-file"); + std::fs::write(&destination_file, b"file").unwrap(); + let destination_error = copy_snapshot_directory_contents( + &source, + &destination_file, + &mut HashMap::new(), + &mut SnapshotBudget::default(), + false, + &mut Vec::new(), + ) + .unwrap_err() + .to_string(); + assert!( + destination_error.contains("failed to create"), + "{destination_error}" + ); + + symlink(temp.path().join("absent-target"), source.join("dangling")).unwrap(); + let dangling_error = copy_snapshot_directory( + &source, + &temp.path().join("dangling-destination"), + &mut HashMap::new(), + &mut SnapshotBudget::default(), + false, + &mut Vec::new(), + ) + .unwrap_err() + .to_string(); + assert!( + dangling_error.contains("failed to resolve"), + "{dangling_error}" + ); + + let closure_cycle = collect_runtime_closure_directory_contents( + &source, + Path::new("runtime"), + false, + &mut vec![canonical], + &mut RuntimeClosureSources::default(), + ) + .unwrap_err() + .to_string(); + assert!(closure_cycle.contains("symlink cycle"), "{closure_cycle}"); + + let depth_error = collect_snapshot_files( + &source, + &source, + &mut Vec::new(), + Some(MAX_SNAPSHOT_DEPTH), + &mut 0, + ) + .unwrap_err() + .to_string(); + assert!(depth_error.contains("traversal depth"), "{depth_error}"); +} + +#[cfg(unix)] +#[test] +fn snapshot_file_and_closure_helpers_cover_external_and_invalid_sources() { + use std::ffi::CString; + use std::os::unix::fs::symlink; + + let temp = tempfile::tempdir().unwrap(); + let plugin_dir = temp.path().join("plugin"); + std::fs::create_dir(&plugin_dir).unwrap(); + let manifest = plugin_dir.join("relay-plugin.toml"); + std::fs::write(&manifest, b"fixture").unwrap(); + let root = temp.path().join("snapshot"); + std::fs::create_dir(&root).unwrap(); + + let missing_error = copy_snapshot_file( + &root, + &manifest, + "missing.bin", + "artifact", + &mut HashMap::new(), + &mut SnapshotBudget::default(), + ) + .unwrap_err() + .to_string(); + assert!( + missing_error.contains("failed to normalize"), + "{missing_error}" + ); + + let root_error = copy_snapshot_file( + &root, + &manifest, + "/", + "library", + &mut HashMap::new(), + &mut SnapshotBudget::default(), + ) + .unwrap_err() + .to_string(); + assert!( + root_error.contains("has no parent directory"), + "{root_error}" ); - let manifest_path = dir.join("relay-plugin.toml"); - std::fs::write( - &manifest_path, - format!( - r#" -manifest_version = 1 -[plugin] -id = "{plugin_id}" -kind = "rust_dynamic" + let external = temp.path().join("external-artifact.bin"); + std::fs::write(&external, b"external artifact").unwrap(); + let (logical, canonical, copied) = copy_snapshot_file( + &root, + &manifest, + external.to_string_lossy().as_ref(), + "artifact", + &mut HashMap::new(), + &mut SnapshotBudget::default(), + ) + .unwrap(); + assert_eq!(logical, external); + assert_eq!(canonical, external.canonicalize().unwrap()); + assert_eq!(std::fs::read(copied).unwrap(), b"external artifact"); + + let mut closure = RuntimeClosureSources::default(); + let closure_missing = + collect_declared_runtime_closure_file(&manifest, "missing.bin", "artifact", &mut closure) + .unwrap_err() + .to_string(); + assert!( + closure_missing.contains("failed to normalize"), + "{closure_missing}" + ); + let closure_root = + collect_declared_runtime_closure_file(&manifest, "/", "library", &mut closure) + .unwrap_err() + .to_string(); + assert!( + closure_root.contains("has no parent directory"), + "{closure_root}" + ); + collect_declared_runtime_closure_file( + &manifest, + external.to_string_lossy().as_ref(), + "artifact", + &mut closure, + ) + .unwrap(); + assert!( + closure + .files + .keys() + .any(|path| path.ends_with("external-artifact.bin")) + ); -[compat] -relay = "0.5" -native_api = "1" + let missing_directory_error = collect_runtime_closure_directory_contents( + &temp.path().join("missing-directory"), + Path::new("runtime"), + false, + &mut Vec::new(), + &mut RuntimeClosureSources::default(), + ) + .unwrap_err() + .to_string(); + assert!( + missing_directory_error.contains("failed to normalize"), + "{missing_directory_error}" + ); -[defaults] -enabled = false + let walk = temp.path().join("walk"); + std::fs::create_dir(&walk).unwrap(); + std::fs::create_dir(walk.join("__pycache__")).unwrap(); + std::fs::write(walk.join("cached.pyc"), b"cache").unwrap(); + std::fs::write(walk.join("module.py"), b"module").unwrap(); + let mut skipped = RuntimeClosureSources::default(); + collect_runtime_closure_directory( + &walk, + Path::new("runtime"), + true, + &mut Vec::new(), + &mut skipped, + ) + .unwrap(); + assert_eq!(skipped.files.len(), 1); + assert!(skipped.files.contains_key(Path::new("runtime/module.py"))); + + symlink(temp.path().join("absent"), walk.join("dangling")).unwrap(); + let dangling_error = collect_runtime_closure_directory( + &walk, + Path::new("runtime"), + false, + &mut Vec::new(), + &mut RuntimeClosureSources::default(), + ) + .unwrap_err() + .to_string(); + assert!( + dangling_error.contains("failed to resolve"), + "{dangling_error}" + ); + std::fs::remove_file(walk.join("dangling")).unwrap(); + + let fifo = walk.join("worker.pipe"); + let fifo_c = CString::new(fifo.as_os_str().as_encoded_bytes()).unwrap(); + // SAFETY: `fifo_c` is a valid NUL-terminated path and the mode contains only permission bits. + assert_eq!(unsafe { libc::mkfifo(fifo_c.as_ptr(), 0o600) }, 0); + let fifo_error = collect_runtime_closure_directory( + &walk, + Path::new("runtime"), + false, + &mut Vec::new(), + &mut RuntimeClosureSources::default(), + ) + .unwrap_err() + .to_string(); + assert!( + fifo_error.contains("regular file or directory"), + "{fifo_error}" + ); -[capabilities] -items = ["plugin_native"] + let one_file = temp.path().join("one-file"); + std::fs::create_dir(&one_file).unwrap(); + std::fs::write(one_file.join("entry"), b"entry").unwrap(); + let mut entries = MAX_SNAPSHOT_FILES; + let entry_error = + collect_snapshot_files(&one_file, &one_file, &mut Vec::new(), None, &mut entries) + .unwrap_err() + .to_string(); + assert!(entry_error.contains("verification budget"), "{entry_error}"); + + make_snapshot_removable(&temp.path().join("already-removed")); +} -[source] -artifact = "libfixture_native.so" +#[test] +fn python_environment_entry_budget_counts_skipped_cache_entries() { + let temp = tempfile::tempdir().unwrap(); + std::fs::write(temp.path().join("ignored.pyc"), b"cache").unwrap(); + std::fs::write(temp.path().join("module.py"), b"module").unwrap(); + std::fs::write(temp.path().join("metadata.txt"), b"metadata").unwrap(); -[integrity] -sha256 = "{digest}" + let error = + environment::test_environment_tree_digest_with_entry_limit(temp.path(), 2).unwrap_err(); -[load] -library = "libfixture_native.so" -symbol = "nemo_relay_fixture_native_plugin" -"#, - digest = digest, - ), - ) - .unwrap(); - manifest_path + assert!(error.contains("2-entry attestation budget"), "{error}"); } -fn materialize_native_example_manifest(dir: &Path) -> (PathBuf, PathBuf) { - let artifact_name = format!( - "{}nemo_relay_rust_native_plugin_example{}", - std::env::consts::DLL_PREFIX, - std::env::consts::DLL_SUFFIX - ); - let artifact_relative = Path::new("target").join("debug").join(&artifact_name); - let artifact_path = dir.join(&artifact_relative); - std::fs::create_dir_all(artifact_path.parent().unwrap()).unwrap(); - let artifact_body = b"native plugin example fixture"; - std::fs::write(&artifact_path, artifact_body).unwrap(); - let digest = Sha256::digest(artifact_body) +#[test] +fn python_activation_snapshot_is_attested_copied_and_tamper_evident() { + let temp = tempfile::tempdir().unwrap(); + let _env = EnvScope::hermetic(&temp); + let plugin_dir = temp.path().join("python-plugin"); + std::fs::create_dir_all(&plugin_dir).unwrap(); + let manifest_path = write_python_dynamic_manifest(&plugin_dir, "acme.python-snapshot"); + let environment_name = Sha256::digest(b"acme.python-snapshot") .iter() .map(|byte| format!("{byte:02x}")) .collect::(); + let environment_path = temp + .path() + .join(environment::MANAGED_ENVIRONMENTS_DIR) + .join(environment_name); + let interpreter = environment::environment_python_path(&environment_path); + std::fs::create_dir_all(interpreter.parent().unwrap()).unwrap(); + std::fs::write(&interpreter, b"attested interpreter").unwrap(); + let installed_module = environment_path + .join("site-packages") + .join("plugin-data.txt"); + std::fs::create_dir_all(installed_module.parent().unwrap()).unwrap(); + std::fs::write(&installed_module, b"safe installed module").unwrap(); + let (manifest, _) = DynamicPluginManifest::load_from_path(&manifest_path).unwrap(); + let source_artifact_sha256 = manifest + .integrity + .as_ref() + .and_then(|integrity| integrity.sha256.as_deref()) + .unwrap(); + environment::write_environment_attestation(&environment_path, source_artifact_sha256).unwrap(); + + let snapshot = DynamicPluginActivationSnapshot::create( + manifest_path.to_string_lossy().as_ref(), + "acme.python-snapshot", + DynamicPluginKind::Worker, + Some(environment_path.to_string_lossy().as_ref()), + &crate::plugins::policy::DynamicPluginHostPolicy::default(), + ) + .unwrap(); + let source_closure_digest = dynamic_plugin_runtime_closure_digest( + manifest_path.to_string_lossy().as_ref(), + Some(environment_path.to_string_lossy().as_ref()), + ) + .unwrap(); + assert_eq!(snapshot.closure_digest(), source_closure_digest); + let copied_environment = PathBuf::from(snapshot.activation_environment_ref().unwrap()); + assert_ne!(copied_environment, environment_path); + assert_eq!( + copied_environment.parent().unwrap().file_name(), + Some(OsStr::new(environment::MANAGED_ENVIRONMENTS_DIR)) + ); + assert_eq!(copied_environment.file_name(), environment_path.file_name()); + assert_eq!( + std::fs::read(copied_environment.join("site-packages/plugin-data.txt")).unwrap(), + b"safe installed module" + ); + let load_error = match load_worker_plugins(vec![WorkerPluginLoadSpec { + plugin_id: "acme.python-snapshot".into(), + manifest_ref: snapshot.activation_manifest_ref(), + environment_ref: snapshot.activation_environment_ref().map(ToOwned::to_owned), + config: Map::new(), + }]) { + Ok(_) => panic!("fixture Python worker unexpectedly activated"), + Err(error) => error.to_string(), + }; + assert!( + !load_error.contains("not the lifecycle-managed path"), + "{load_error}" + ); - let repository_root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); - let template = std::fs::read_to_string( - repository_root.join("examples/rust-native-plugin/relay-plugin.toml"), + std::fs::write(&installed_module, b"tampered installed module").unwrap(); + + assert!( + environment::verify_environment_attestation(&environment_path, source_artifact_sha256) + .is_err() + ); + assert_eq!( + std::fs::read(copied_environment.join("site-packages/plugin-data.txt")).unwrap(), + b"safe installed module" + ); + snapshot.verify_current().unwrap(); + dynamic_plugin_runtime_closure_digest( + manifest_path.to_string_lossy().as_ref(), + Some(environment_path.to_string_lossy().as_ref()), + ) + .expect("hook preflight should authenticate the attestation without rehashing the environment"); + let changed_error = DynamicPluginActivationSnapshot::create( + manifest_path.to_string_lossy().as_ref(), + "acme.python-snapshot", + DynamicPluginKind::Worker, + Some(environment_path.to_string_lossy().as_ref()), + &crate::plugins::policy::DynamicPluginHostPolicy::default(), + ) + .unwrap_err() + .to_string(); + assert!( + changed_error.contains("changed after provisioning"), + "{changed_error}" + ); + let attestation_path = environment_path.join(environment::ENVIRONMENT_ATTESTATION_FILE); + let mut forged: serde_json::Value = + serde_json::from_slice(&std::fs::read(&attestation_path).unwrap()).unwrap(); + forged["environment_sha256"] = + serde_json::json!(environment::environment_tree_digest(&environment_path).unwrap()); + std::fs::write( + &attestation_path, + serde_json::to_vec_pretty(&forged).unwrap(), ) .unwrap(); - let config_schema = - std::fs::read(repository_root.join("examples/rust-native-plugin/config.schema.json")) - .unwrap(); - let manifest = template - .replace("", &artifact_name) - .replace("", &digest); - let manifest_path = dir.join("relay-plugin.toml"); - std::fs::write(&manifest_path, manifest).unwrap(); - std::fs::write(dir.join("config.schema.json"), config_schema).unwrap(); - (manifest_path, artifact_path) + let error = DynamicPluginActivationSnapshot::create( + manifest_path.to_string_lossy().as_ref(), + "acme.python-snapshot", + DynamicPluginKind::Worker, + Some(environment_path.to_string_lossy().as_ref()), + &crate::plugins::policy::DynamicPluginHostPolicy::default(), + ) + .unwrap_err() + .to_string(); + assert!(error.contains("failed authentication"), "{error}"); } #[test] -fn tracked_native_plugin_example_satisfies_default_trust_policy() { +fn python_entrypoint_validation_reports_each_authored_contract_error() { let temp = tempfile::tempdir().unwrap(); - let _env = EnvScope::hermetic(&temp); - let _cwd = CurrentDirGuard::enter(temp.path()); - let plugin_dir = temp.path().join("plugins").join("native-example"); + let plugin_dir = temp.path().join("plugin"); std::fs::create_dir_all(&plugin_dir).unwrap(); - materialize_native_example_manifest(&plugin_dir); + let manifest_path = write_python_dynamic_manifest(&plugin_dir, "acme.python-validation"); + let (manifest, manifest_ref) = DynamicPluginManifest::load_from_path(&manifest_path).unwrap(); - add( - PluginsAddCommand { - scope: PluginsScopeArgs { - project: true, - ..PluginsScopeArgs::default() - }, - path: plugin_dir, - }, - &ServerArgs::default(), - ) - .unwrap(); + let mut missing_source = manifest.clone(); + missing_source.source = None; + let error = environment::validate_python_entrypoint_artifact(&missing_source, &manifest_ref) + .unwrap_err(); + assert!( + error.contains("must declare source.manifest_root"), + "{error}" + ); - let resolved = resolve_plugins_config(None).unwrap(); - assert_eq!(resolved.dynamic_plugins.len(), 1); - assert_eq!( - resolved.dynamic_plugins[0].plugin_id, - "examples.rust_native_policy" + let mut missing_separator = manifest.clone(); + let DynamicPluginManifestLoad::Worker(load) = &mut missing_separator.load else { + panic!("fixture must be a worker plugin"); + }; + load.entrypoint = Some("plugin".into()); + let error = environment::validate_python_entrypoint_artifact(&missing_separator, &manifest_ref) + .unwrap_err(); + assert!(error.contains("module:function form"), "{error}"); + + let mut extra_separator = manifest.clone(); + let DynamicPluginManifestLoad::Worker(load) = &mut extra_separator.load else { + panic!("fixture must be a worker plugin"); + }; + load.entrypoint = Some("plugin:main:extra".into()); + let error = environment::validate_python_entrypoint_artifact(&extra_separator, &manifest_ref) + .unwrap_err(); + assert!(error.contains("module:function form"), "{error}"); + + let mut empty_module = manifest.clone(); + let DynamicPluginManifestLoad::Worker(load) = &mut empty_module.load else { + panic!("fixture must be a worker plugin"); + }; + load.entrypoint = Some(":main".into()); + let error = + environment::validate_python_entrypoint_artifact(&empty_module, &manifest_ref).unwrap_err(); + assert!(error.contains("module:function form"), "{error}"); + + let mut missing_root = manifest; + missing_root + .source + .as_mut() + .expect("fixture declares source") + .manifest_root = Some("missing-root".into()); + let error = + environment::validate_python_entrypoint_artifact(&missing_root, &manifest_ref).unwrap_err(); + assert!( + error.contains("could not resolve Python plugin source.manifest_root"), + "{error}" ); } #[test] -fn tracked_native_plugin_example_rejects_tampered_artifact() { +fn python_environment_attestation_rejects_invalid_json_and_source_identity_drift() { let temp = tempfile::tempdir().unwrap(); - let _env = EnvScope::hermetic(&temp); - let _cwd = CurrentDirGuard::enter(temp.path()); - let plugin_dir = temp.path().join("plugins").join("native-example"); - std::fs::create_dir_all(&plugin_dir).unwrap(); - let (_, artifact_path) = materialize_native_example_manifest(&plugin_dir); - std::fs::write(artifact_path, b"tampered native plugin example fixture").unwrap(); + let environment_path = temp.path().join("environment"); + std::fs::create_dir_all(&environment_path).unwrap(); + let attestation_path = environment_path.join(environment::ENVIRONMENT_ATTESTATION_FILE); - let error = add( - PluginsAddCommand { - scope: PluginsScopeArgs { - project: true, - ..PluginsScopeArgs::default() - }, - path: plugin_dir, - }, - &ServerArgs::default(), - ) - .unwrap_err(); + std::fs::write(&attestation_path, "{not-json").unwrap(); + let error = + environment::read_environment_attestation(&environment_path, "expected").unwrap_err(); + assert!(error.contains("attestation"), "{error}"); + assert!(error.contains("is invalid"), "{error}"); - match error { - CliError::PluginLifecycle { - kind: PluginLifecycleFailureKind::Refused, - code: Some("integrity_failed"), - message, - .. - } => assert!(message.contains("failed integrity verification")), - other => panic!("unexpected integrity add error: {other}"), - } + std::fs::write( + &attestation_path, + serde_json::to_vec(&serde_json::json!({ + "version": 1, + "source_artifact_sha256": "different", + "environment_sha256": "0".repeat(64), + "authentication": "unused" + })) + .unwrap(), + ) + .unwrap(); + let error = + environment::read_environment_attestation(&environment_path, "expected").unwrap_err(); assert!( - resolve_plugins_config(None) - .unwrap() - .dynamic_plugins - .is_empty() + error.contains("does not match the trusted source artifact"), + "{error}" ); } @@ -602,14 +1756,11 @@ fn add_registers_dynamic_plugin_in_project_plugins_toml() { write_dynamic_manifest(&plugin_dir, "acme.guardrail"); add( - PluginsAddCommand { - scope: PluginsScopeArgs { - project: true, - ..PluginsScopeArgs::default() - }, + PluginsAddRequest { + scope: ConfigurationScope::Project, path: plugin_dir.clone(), }, - &crate::config::ServerArgs::default(), + &crate::server::GatewayOverrides::default(), ) .unwrap(); @@ -641,14 +1792,11 @@ fn add_rejects_unreadable_declared_config_schema() { std::fs::remove_file(plugin_dir.join("config.schema.json")).unwrap(); let error = add( - PluginsAddCommand { - scope: PluginsScopeArgs { - project: true, - ..PluginsScopeArgs::default() - }, + PluginsAddRequest { + scope: ConfigurationScope::Project, path: plugin_dir, }, - &ServerArgs::default(), + &GatewayOverrides::default(), ) .unwrap_err() .to_string(); @@ -675,11 +1823,11 @@ fn validate_path_rejects_invalid_declared_config_schema() { ); let error = validate( - PluginsValidateCommand { + PluginsValidateRequest { target: plugin_dir.to_string_lossy().into_owned(), json: false, }, - &ServerArgs::default(), + &GatewayOverrides::default(), ) .unwrap_err() .to_string(); @@ -704,13 +1852,10 @@ fn validate_id_checks_resolved_host_config_against_declared_schema() { "properties": {"port": {"type": "integer"}} }), ); - let server = ServerArgs::default(); + let server = GatewayOverrides::default(); add( - PluginsAddCommand { - scope: PluginsScopeArgs { - project: true, - ..PluginsScopeArgs::default() - }, + PluginsAddRequest { + scope: ConfigurationScope::Project, path: plugin_dir, }, &server, @@ -728,7 +1873,7 @@ port = "not-an-integer" std::fs::write(&plugins_toml, rendered).unwrap(); let error = validate( - PluginsValidateCommand { + PluginsValidateRequest { target: "acme.schema-config".into(), json: false, }, @@ -750,14 +1895,11 @@ fn add_provisions_persists_and_removes_managed_python_environment() { std::fs::create_dir_all(&plugin_dir).unwrap(); write_python_dynamic_manifest(&plugin_dir, " acme.python "); let runner = FakePythonEnvironmentRunner::default(); - let server = ServerArgs::default(); + let server = GatewayOverrides::default(); add_with_environment_runner( - PluginsAddCommand { - scope: PluginsScopeArgs { - project: true, - ..PluginsScopeArgs::default() - }, + PluginsAddRequest { + scope: ConfigurationScope::Project, path: plugin_dir.clone(), }, &server, @@ -848,7 +1990,7 @@ fn add_provisions_persists_and_removes_managed_python_environment() { .any(|arg| arg == "-e" || arg == "--editable") ); enable( - PluginsEnableCommand { + PluginsEnableRequest { id: "acme.python".into(), }, &server, @@ -862,7 +2004,7 @@ fn add_provisions_persists_and_removes_managed_python_environment() { let stale_marker = environment_path.join("stale-marker"); std::fs::write(&stale_marker, b"stale").unwrap(); remove( - PluginsRemoveCommand { + PluginsRemoveRequest { id: "acme.python".into(), }, &server, @@ -877,11 +2019,8 @@ fn add_provisions_persists_and_removes_managed_python_environment() { assert_eq!(removed.record.source.environment_ref, None); add_with_environment_runner( - PluginsAddCommand { - scope: PluginsScopeArgs { - project: true, - ..PluginsScopeArgs::default() - }, + PluginsAddRequest { + scope: ConfigurationScope::Project, path: plugin_dir, }, &server, @@ -903,14 +2042,11 @@ fn add_rolls_back_python_environment_when_installation_fails() { let runner = FakePythonEnvironmentRunner::failing_install(); let error = add_with_environment_runner( - PluginsAddCommand { - scope: PluginsScopeArgs { - project: true, - ..PluginsScopeArgs::default() - }, + PluginsAddRequest { + scope: ConfigurationScope::Project, path: plugin_dir, }, - &ServerArgs::default(), + &GatewayOverrides::default(), &runner, ) .expect_err("pip failure should abort plugin registration"); @@ -944,13 +2080,10 @@ fn enable_rejects_missing_managed_python_environment() { std::fs::create_dir_all(&plugin_dir).unwrap(); write_python_dynamic_manifest(&plugin_dir, "acme.python-missing"); let runner = FakePythonEnvironmentRunner::default(); - let server = ServerArgs::default(); + let server = GatewayOverrides::default(); add_with_environment_runner( - PluginsAddCommand { - scope: PluginsScopeArgs { - project: true, - ..PluginsScopeArgs::default() - }, + PluginsAddRequest { + scope: ConfigurationScope::Project, path: plugin_dir, }, &server, @@ -969,7 +2102,7 @@ fn enable_rejects_missing_managed_python_environment() { std::fs::remove_dir_all(&environment_ref).unwrap(); let error = enable( - PluginsEnableCommand { + PluginsEnableRequest { id: "acme.python-missing".into(), }, &server, @@ -1003,13 +2136,10 @@ fn enable_rejects_python_environment_outside_managed_location() { std::fs::create_dir_all(&plugin_dir).unwrap(); write_python_dynamic_manifest(&plugin_dir, "acme.python-outside"); let runner = FakePythonEnvironmentRunner::default(); - let server = ServerArgs::default(); + let server = GatewayOverrides::default(); add_with_environment_runner( - PluginsAddCommand { - scope: PluginsScopeArgs { - project: true, - ..PluginsScopeArgs::default() - }, + PluginsAddRequest { + scope: ConfigurationScope::Project, path: plugin_dir, }, &server, @@ -1037,7 +2167,7 @@ fn enable_rejects_python_environment_outside_managed_location() { scope.save().unwrap(); let error = enable( - PluginsEnableCommand { + PluginsEnableRequest { id: "acme.python-outside".into(), }, &server, @@ -1078,14 +2208,11 @@ fn add_requires_manifest_root_for_python_workers() { let runner = FakePythonEnvironmentRunner::default(); let error = add_with_environment_runner( - PluginsAddCommand { - scope: PluginsScopeArgs { - project: true, - ..PluginsScopeArgs::default() - }, + PluginsAddRequest { + scope: ConfigurationScope::Project, path: plugin_dir, }, - &ServerArgs::default(), + &GatewayOverrides::default(), &runner, ) .expect_err("Python plugins without manifest_root should fail"); @@ -1099,6 +2226,69 @@ fn add_requires_manifest_root_for_python_workers() { assert!(runner.calls().is_empty()); } +#[test] +fn add_rejects_python_entrypoint_module_that_is_not_integrity_checked_artifact() { + let temp = tempfile::tempdir().unwrap(); + let _env = EnvScope::hermetic(&temp); + let _cwd = CurrentDirGuard::enter(temp.path()); + let plugin_dir = temp.path().join("plugins").join("python"); + std::fs::create_dir_all(&plugin_dir).unwrap(); + let manifest = write_python_dynamic_manifest(&plugin_dir, "acme.unsigned-entrypoint"); + std::fs::write( + plugin_dir.join("unsigned_sibling.py"), + b"def main(): pass\n", + ) + .unwrap(); + let contents = std::fs::read_to_string(&manifest).unwrap().replace( + "entrypoint = \"plugin:main\"", + "entrypoint = \"unsigned_sibling:main\"", + ); + std::fs::write(&manifest, contents).unwrap(); + let runner = FakePythonEnvironmentRunner::default(); + + let error = add_with_environment_runner( + PluginsAddRequest { + scope: ConfigurationScope::Project, + path: plugin_dir, + }, + &GatewayOverrides::default(), + &runner, + ) + .expect_err("an unsigned sibling module must not become the executed entrypoint"); + + let (_, _, kind, code, message) = error + .as_plugin_lifecycle_error_context() + .expect("environment refusal should be structured"); + assert_eq!(kind, PluginLifecycleFailureKind::Failed); + assert_eq!(code, Some("environment_failed")); + assert!(message.contains("executed entrypoint module"), "{message}"); + assert!(message.contains("integrity-checked artifact"), "{message}"); + assert!(runner.calls().is_empty()); +} + +#[test] +fn activation_snapshot_rejects_ambiguous_python_entrypoint_module() { + let temp = tempfile::tempdir().unwrap(); + let plugin_dir = temp.path().join("python-plugin"); + std::fs::create_dir_all(plugin_dir.join("plugin")).unwrap(); + let manifest = write_python_dynamic_manifest(&plugin_dir, "acme.ambiguous-entrypoint"); + std::fs::write(plugin_dir.join("plugin/__init__.py"), b"def main(): pass\n").unwrap(); + + let error = DynamicPluginActivationSnapshot::create( + manifest.to_string_lossy().as_ref(), + "acme.ambiguous-entrypoint", + DynamicPluginKind::Worker, + None, + &crate::plugins::policy::DynamicPluginHostPolicy::default(), + ) + .unwrap_err() + .to_string(); + + assert!(error.contains("exactly one source module"), "{error}"); + assert!(error.contains("plugin.py"), "{error}"); + assert!(error.contains("__init__.py"), "{error}"); +} + #[test] fn managed_environment_cleanup_refuses_paths_outside_lifecycle_directory() { let temp = tempfile::tempdir().unwrap(); @@ -1158,13 +2348,10 @@ fn remove_can_retry_after_guarded_environment_cleanup_failure() { std::fs::create_dir_all(&plugin_dir).unwrap(); write_python_dynamic_manifest(&plugin_dir, "acme.python-retry"); let runner = FakePythonEnvironmentRunner::default(); - let server = ServerArgs::default(); + let server = GatewayOverrides::default(); add_with_environment_runner( - PluginsAddCommand { - scope: PluginsScopeArgs { - project: true, - ..PluginsScopeArgs::default() - }, + PluginsAddRequest { + scope: ConfigurationScope::Project, path: plugin_dir, }, &server, @@ -1198,7 +2385,7 @@ fn remove_can_retry_after_guarded_environment_cleanup_failure() { scope.save().unwrap(); let error = remove( - PluginsRemoveCommand { + PluginsRemoveRequest { id: "acme.python-retry".into(), }, &server, @@ -1229,7 +2416,7 @@ fn remove_can_retry_after_guarded_environment_cleanup_failure() { scope.save().unwrap(); remove( - PluginsRemoveCommand { + PluginsRemoveRequest { id: "acme.python-retry".into(), }, &server, @@ -1254,14 +2441,11 @@ fn active_dynamic_plugin_components_project_enabled_native_records_only() { let plugin_dir = temp.path().join("plugins").join("native"); std::fs::create_dir_all(&plugin_dir).unwrap(); write_native_dynamic_manifest(&plugin_dir, "acme.native"); - let server = crate::config::ServerArgs::default(); + let server = crate::server::GatewayOverrides::default(); add( - PluginsAddCommand { - scope: PluginsScopeArgs { - project: true, - ..PluginsScopeArgs::default() - }, + PluginsAddRequest { + scope: ConfigurationScope::Project, path: plugin_dir, }, &server, @@ -1273,7 +2457,7 @@ fn active_dynamic_plugin_components_project_enabled_native_records_only() { assert!(inactive.is_empty()); enable( - PluginsEnableCommand { + PluginsEnableRequest { id: "acme.native".into(), }, &server, @@ -1301,21 +2485,18 @@ fn active_dynamic_plugin_components_accept_enabled_worker_records() { let plugin_dir = temp.path().join("plugins").join("worker"); std::fs::create_dir_all(&plugin_dir).unwrap(); write_dynamic_manifest(&plugin_dir, "acme.worker"); - let server = crate::config::ServerArgs::default(); + let server = crate::server::GatewayOverrides::default(); add( - PluginsAddCommand { - scope: PluginsScopeArgs { - project: true, - ..PluginsScopeArgs::default() - }, + PluginsAddRequest { + scope: ConfigurationScope::Project, path: plugin_dir, }, &server, ) .unwrap(); enable( - PluginsEnableCommand { + PluginsEnableRequest { id: "acme.worker".into(), }, &server, @@ -1344,21 +2525,18 @@ fn active_dynamic_plugin_components_accept_worker_records_without_manifest_ref() let plugin_dir = temp.path().join("plugins").join("worker"); std::fs::create_dir_all(&plugin_dir).unwrap(); write_dynamic_manifest(&plugin_dir, "acme.worker"); - let server = crate::config::ServerArgs::default(); + let server = crate::server::GatewayOverrides::default(); add( - PluginsAddCommand { - scope: PluginsScopeArgs { - project: true, - ..PluginsScopeArgs::default() - }, + PluginsAddRequest { + scope: ConfigurationScope::Project, path: plugin_dir, }, &server, ) .unwrap(); enable( - PluginsEnableCommand { + PluginsEnableRequest { id: "acme.worker".into(), }, &server, @@ -1399,26 +2577,20 @@ fn add_rejects_duplicate_dynamic_plugin_ids() { write_dynamic_manifest(&plugin_dir, "acme.guardrail"); add( - PluginsAddCommand { - scope: PluginsScopeArgs { - project: true, - ..PluginsScopeArgs::default() - }, + PluginsAddRequest { + scope: ConfigurationScope::Project, path: plugin_dir.clone(), }, - &crate::config::ServerArgs::default(), + &crate::server::GatewayOverrides::default(), ) .unwrap(); let error = add( - PluginsAddCommand { - scope: PluginsScopeArgs { - project: true, - ..PluginsScopeArgs::default() - }, + PluginsAddRequest { + scope: ConfigurationScope::Project, path: plugin_dir, }, - &crate::config::ServerArgs::default(), + &crate::server::GatewayOverrides::default(), ) .unwrap_err() .to_string(); @@ -1437,17 +2609,14 @@ fn add_rejects_scope_flags_when_explicit_config_is_set() { let config_path = config_dir.join("gateway.toml"); std::fs::write(&config_path, "").unwrap(); - let server = ServerArgs { + let server = GatewayOverrides { config: Some(config_path), - ..ServerArgs::default() + ..GatewayOverrides::default() }; let error = add( - PluginsAddCommand { - scope: PluginsScopeArgs { - project: true, - ..PluginsScopeArgs::default() - }, + PluginsAddRequest { + scope: ConfigurationScope::Project, path: plugin_dir, }, &server, @@ -1477,14 +2646,11 @@ allowed = false .unwrap(); let error = add( - PluginsAddCommand { - scope: PluginsScopeArgs { - project: true, - ..PluginsScopeArgs::default() - }, + PluginsAddRequest { + scope: ConfigurationScope::Project, path: plugin_dir, }, - &crate::config::ServerArgs::default(), + &crate::server::GatewayOverrides::default(), ) .unwrap_err(); @@ -1551,14 +2717,11 @@ fn list_and_inspect_render_discovered_dynamic_plugins() { write_dynamic_manifest(&plugin_dir, "acme.guardrail"); add( - PluginsAddCommand { - scope: PluginsScopeArgs { - project: true, - ..PluginsScopeArgs::default() - }, + PluginsAddRequest { + scope: ConfigurationScope::Project, path: plugin_dir, }, - &crate::config::ServerArgs::default(), + &crate::server::GatewayOverrides::default(), ) .unwrap(); @@ -1623,14 +2786,11 @@ fn validate_renders_summary_for_path_and_id_targets() { let manifest_path = write_dynamic_manifest(&plugin_dir, "acme.guardrail"); add( - PluginsAddCommand { - scope: PluginsScopeArgs { - project: true, - ..PluginsScopeArgs::default() - }, + PluginsAddRequest { + scope: ConfigurationScope::Project, path: plugin_dir, }, - &crate::config::ServerArgs::default(), + &crate::server::GatewayOverrides::default(), ) .unwrap(); @@ -1675,22 +2835,22 @@ fn validate_renders_summary_for_path_and_id_targets() { assert!(id_summary.contains("desired.enabled: false")); let missing_validate = validate( - PluginsValidateCommand { + PluginsValidateRequest { target: "missing.plugin".into(), json: false, }, - &crate::config::ServerArgs::default(), + &crate::server::GatewayOverrides::default(), ) .unwrap_err() .to_string(); assert!(missing_validate.contains("not registered")); let missing_inspect = inspect( - PluginsInspectCommand { + PluginsInspectRequest { id: "missing.plugin".into(), json: false, }, - &crate::config::ServerArgs::default(), + &crate::server::GatewayOverrides::default(), ) .unwrap_err() .to_string(); @@ -1698,8 +2858,8 @@ fn validate_renders_summary_for_path_and_id_targets() { assert_eq!( list( - PluginsListCommand::default(), - &crate::config::ServerArgs::default() + PluginsListRequest::default(), + &crate::server::GatewayOverrides::default() ) .unwrap(), () @@ -1714,14 +2874,11 @@ fn enable_disable_and_remove_persist_lifecycle_state() { let plugin_dir = temp.path().join("plugins").join("acme"); std::fs::create_dir_all(&plugin_dir).unwrap(); write_dynamic_manifest(&plugin_dir, "acme.guardrail"); - let server = crate::config::ServerArgs::default(); + let server = crate::server::GatewayOverrides::default(); add( - PluginsAddCommand { - scope: PluginsScopeArgs { - project: true, - ..PluginsScopeArgs::default() - }, + PluginsAddRequest { + scope: ConfigurationScope::Project, path: plugin_dir, }, &server, @@ -1729,7 +2886,7 @@ fn enable_disable_and_remove_persist_lifecycle_state() { .unwrap(); enable( - PluginsEnableCommand { + PluginsEnableRequest { id: "acme.guardrail".into(), }, &server, @@ -1744,7 +2901,7 @@ fn enable_disable_and_remove_persist_lifecycle_state() { assert!(enabled.record.spec.enabled); disable( - PluginsDisableCommand { + PluginsDisableRequest { id: "acme.guardrail".into(), }, &server, @@ -1758,7 +2915,7 @@ fn enable_disable_and_remove_persist_lifecycle_state() { assert!(!disabled.record.spec.enabled); remove( - PluginsRemoveCommand { + PluginsRemoveRequest { id: "acme.guardrail".into(), }, &server, @@ -1783,7 +2940,7 @@ fn enable_disable_and_remove_persist_lifecycle_state() { assert!(all_list.contains("tombstoned")); let error = enable( - PluginsEnableCommand { + PluginsEnableRequest { id: "acme.guardrail".into(), }, &server, @@ -1811,14 +2968,14 @@ fn add_with_explicit_config_uses_sibling_plugins_and_state_files() { let config_path = config_dir.join("gateway.toml"); std::fs::write(&config_path, "").unwrap(); - let server = ServerArgs { + let server = GatewayOverrides { config: Some(config_path), - ..ServerArgs::default() + ..GatewayOverrides::default() }; add( - PluginsAddCommand { - scope: PluginsScopeArgs::default(), + PluginsAddRequest { + scope: ConfigurationScope::default(), path: plugin_dir, }, &server, @@ -1925,10 +3082,10 @@ fn manually_configured_python_worker_cannot_enable_without_lifecycle_add() { assert!(summary.contains("runtime environment is unavailable")); let error = enable( - PluginsEnableCommand { + PluginsEnableRequest { id: "acme.python-direct".into(), }, - &ServerArgs::default(), + &GatewayOverrides::default(), ) .expect_err("manually configured Python workers must not activate"); let (_, _, kind, code, message) = error @@ -2028,14 +3185,11 @@ fn hydrate_persists_updated_policy_and_error_state() { write_dynamic_manifest(&plugin_dir, "acme.persist-blocked"); add( - PluginsAddCommand { - scope: PluginsScopeArgs { - project: true, - ..PluginsScopeArgs::default() - }, + PluginsAddRequest { + scope: ConfigurationScope::Project, path: plugin_dir.clone(), }, - &ServerArgs::default(), + &GatewayOverrides::default(), ) .unwrap(); @@ -2300,14 +3454,11 @@ fn enable_refuses_dynamic_plugins_blocked_by_host_policy_and_persists_status() { std::fs::create_dir_all(&plugin_dir).unwrap(); std::fs::create_dir_all(&config_dir).unwrap(); let manifest_path = write_dynamic_manifest(&plugin_dir, "acme.enable-blocked"); - let server = crate::config::ServerArgs::default(); + let server = crate::server::GatewayOverrides::default(); add( - PluginsAddCommand { - scope: PluginsScopeArgs { - project: true, - ..PluginsScopeArgs::default() - }, + PluginsAddRequest { + scope: ConfigurationScope::Project, path: plugin_dir, }, &server, @@ -2329,7 +3480,7 @@ fn enable_refuses_dynamic_plugins_blocked_by_host_policy_and_persists_status() { .unwrap(); let error = enable( - PluginsEnableCommand { + PluginsEnableRequest { id: "acme.enable-blocked".into(), }, &server, @@ -2381,14 +3532,11 @@ fn disable_succeeds_when_registered_plugin_manifest_is_unreadable() { let plugin_dir = temp.path().join("plugins").join("acme"); std::fs::create_dir_all(&plugin_dir).unwrap(); write_dynamic_manifest(&plugin_dir, "acme.guardrail"); - let server = crate::config::ServerArgs::default(); + let server = crate::server::GatewayOverrides::default(); add( - PluginsAddCommand { - scope: PluginsScopeArgs { - project: true, - ..PluginsScopeArgs::default() - }, + PluginsAddRequest { + scope: ConfigurationScope::Project, path: plugin_dir.clone(), }, &server, @@ -2396,7 +3544,7 @@ fn disable_succeeds_when_registered_plugin_manifest_is_unreadable() { .unwrap(); enable( - PluginsEnableCommand { + PluginsEnableRequest { id: "acme.guardrail".into(), }, &server, @@ -2406,7 +3554,7 @@ fn disable_succeeds_when_registered_plugin_manifest_is_unreadable() { std::fs::remove_file(plugin_dir.join("relay-plugin.toml")).unwrap(); disable( - PluginsDisableCommand { + PluginsDisableRequest { id: "acme.guardrail".into(), }, &server, @@ -2430,14 +3578,11 @@ fn validate_marks_registered_plugins_invalid_when_host_policy_blocks_them() { std::fs::create_dir_all(&plugin_dir).unwrap(); std::fs::create_dir_all(&config_dir).unwrap(); let manifest_path = write_dynamic_manifest(&plugin_dir, "acme.validate-blocked"); - let server = crate::config::ServerArgs::default(); + let server = crate::server::GatewayOverrides::default(); add( - PluginsAddCommand { - scope: PluginsScopeArgs { - project: true, - ..PluginsScopeArgs::default() - }, + PluginsAddRequest { + scope: ConfigurationScope::Project, path: plugin_dir, }, &server, @@ -2461,7 +3606,7 @@ fn validate_marks_registered_plugins_invalid_when_host_policy_blocks_them() { .unwrap(); validate( - PluginsValidateCommand { + PluginsValidateRequest { target: "acme.validate-blocked".into(), json: false, }, @@ -2573,14 +3718,11 @@ fn add_can_revive_tombstoned_records() { let plugin_dir = temp.path().join("plugins").join("acme"); std::fs::create_dir_all(&plugin_dir).unwrap(); write_dynamic_manifest(&plugin_dir, "acme.revive"); - let server = crate::config::ServerArgs::default(); + let server = crate::server::GatewayOverrides::default(); add( - PluginsAddCommand { - scope: PluginsScopeArgs { - project: true, - ..PluginsScopeArgs::default() - }, + PluginsAddRequest { + scope: ConfigurationScope::Project, path: plugin_dir.clone(), }, &server, @@ -2588,7 +3730,7 @@ fn add_can_revive_tombstoned_records() { .unwrap(); remove( - PluginsRemoveCommand { + PluginsRemoveRequest { id: "acme.revive".into(), }, &server, @@ -2596,11 +3738,8 @@ fn add_can_revive_tombstoned_records() { .unwrap(); add( - PluginsAddCommand { - scope: PluginsScopeArgs { - project: true, - ..PluginsScopeArgs::default() - }, + PluginsAddRequest { + scope: ConfigurationScope::Project, path: plugin_dir, }, &server, @@ -2624,14 +3763,11 @@ fn json_helpers_emit_stable_success_and_failure_shapes() { let plugin_dir = temp.path().join("plugins").join("acme"); std::fs::create_dir_all(&plugin_dir).unwrap(); let manifest_path = write_dynamic_manifest(&plugin_dir, "acme.json"); - let server = ServerArgs::default(); + let server = GatewayOverrides::default(); add( - PluginsAddCommand { - scope: PluginsScopeArgs { - project: true, - ..PluginsScopeArgs::default() - }, + PluginsAddRequest { + scope: ConfigurationScope::Project, path: plugin_dir, }, &server, @@ -2758,14 +3894,11 @@ fn remove_tolerates_unreadable_non_target_manifest_entries() { std::fs::create_dir_all(&plugin_dir).unwrap(); std::fs::create_dir_all(&broken_dir).unwrap(); let manifest_path = write_dynamic_manifest(&plugin_dir, "acme.guardrail"); - let server = ServerArgs::default(); + let server = GatewayOverrides::default(); add( - PluginsAddCommand { - scope: PluginsScopeArgs { - project: true, - ..PluginsScopeArgs::default() - }, + PluginsAddRequest { + scope: ConfigurationScope::Project, path: plugin_dir, }, &server, @@ -2784,7 +3917,7 @@ fn remove_tolerates_unreadable_non_target_manifest_entries() { .unwrap(); remove( - PluginsRemoveCommand { + PluginsRemoveRequest { id: "acme.guardrail".into(), }, &server, @@ -2885,14 +4018,11 @@ fn inspect_redacts_host_config_values() { let plugin_dir = temp.path().join("plugins").join("acme"); std::fs::create_dir_all(&plugin_dir).unwrap(); let manifest_path = write_dynamic_manifest(&plugin_dir, "acme.redacted"); - let server = ServerArgs::default(); + let server = GatewayOverrides::default(); add( - PluginsAddCommand { - scope: PluginsScopeArgs { - project: true, - ..PluginsScopeArgs::default() - }, + PluginsAddRequest { + scope: ConfigurationScope::Project, path: plugin_dir, }, &server, @@ -2965,14 +4095,11 @@ fn inspect_distinguishes_empty_host_config_from_missing_host_config() { let plugin_dir = temp.path().join("plugins").join("acme"); std::fs::create_dir_all(&plugin_dir).unwrap(); let manifest_path = write_dynamic_manifest(&plugin_dir, "acme.empty-config"); - let server = ServerArgs::default(); + let server = GatewayOverrides::default(); add( - PluginsAddCommand { - scope: PluginsScopeArgs { - project: true, - ..PluginsScopeArgs::default() - }, + PluginsAddRequest { + scope: ConfigurationScope::Project, path: plugin_dir, }, &server, diff --git a/crates/cli/tests/coverage/plugins_schema_tests.rs b/crates/cli/tests/coverage/shared/plugins_schema_tests.rs similarity index 96% rename from crates/cli/tests/coverage/plugins_schema_tests.rs rename to crates/cli/tests/coverage/shared/plugins_schema_tests.rs index 3221fb857..e126f62e7 100644 --- a/crates/cli/tests/coverage/plugins_schema_tests.rs +++ b/crates/cli/tests/coverage/shared/plugins_schema_tests.rs @@ -194,6 +194,40 @@ fn resolves_percent_encoded_local_references() { ); } +#[test] +fn resolves_fragments_within_the_active_nested_schema_resource() { + let loaded = load(&json!({ + "$schema": DRAFT2020, + "type": "object", + "$defs": { + "rootValue": {"$anchor": "value", "type": "integer"}, + "child": { + "$id": "child.json", + "type": "object", + "$defs": { + "text": {"$anchor": "value", "type": "string"} + }, + "properties": { + "name": {"$ref": "#value"} + } + } + }, + "properties": { + "child": {"$ref": "#/$defs/child"} + } + })); + + let child = loaded + .fields() + .iter() + .find(|field| field.key == "child") + .expect("child field"); + assert!(matches!(child.kind, DynamicConfigFieldKind::Object { .. })); + loaded + .validate(&json!({"child": {"name": "relay"}})) + .expect("nested anchor resolves within child resource"); +} + #[test] fn canonicalizes_reference_fragments_and_rejects_malformed_encoding() { let schema = json!({ diff --git a/crates/cli/tests/coverage/plugins_tests.rs b/crates/cli/tests/coverage/shared/plugins_tests.rs similarity index 97% rename from crates/cli/tests/coverage/plugins_tests.rs rename to crates/cli/tests/coverage/shared/plugins_tests.rs index 17c03fd20..cb4e3b9f2 100644 --- a/crates/cli/tests/coverage/plugins_tests.rs +++ b/crates/cli/tests/coverage/shared/plugins_tests.rs @@ -2,10 +2,10 @@ // SPDX-License-Identifier: Apache-2.0 use super::*; -use crate::config::{ - PluginsScopeArgs, global_plugin_config_path, project_plugin_config_path, - user_plugin_config_path, +use crate::configuration::{ + global_plugin_config_path, project_plugin_config_path, user_plugin_config_path, }; +use crate::plugins::ConfigurationScope; use nemo_relay::config_editor::{ EditorConfig, EditorListItemSpec, EditorSchema, EditorTaggedUnionSpec, EditorVariantSpec, }; @@ -154,33 +154,21 @@ fn local_llm_guardrails_component_config(config_yaml: &str) -> serde_json::Map GatewayConfig { anthropic_base_url: "http://127.0.0.1".into(), metadata: None, plugin_config: None, - max_hook_payload_bytes: crate::config::DEFAULT_MAX_HOOK_PAYLOAD_BYTES, - max_passthrough_body_bytes: crate::config::DEFAULT_MAX_PASSTHROUGH_BODY_BYTES, + max_hook_payload_bytes: crate::configuration::DEFAULT_MAX_HOOK_PAYLOAD_BYTES, + max_passthrough_body_bytes: crate::configuration::DEFAULT_MAX_PASSTHROUGH_BODY_BYTES, } } @@ -371,7 +372,214 @@ async fn healthz_returns_ok() { assert_eq!(response.status(), StatusCode::OK); let bytes = response.into_body().collect().await.unwrap().to_bytes(); let body: Value = serde_json::from_slice(&bytes).unwrap(); - assert_eq!(body, json!({ "status": "ok" })); + assert_eq!(body["status"], json!("ok")); + assert_eq!(body["service"], json!("nemo-relay")); + assert_eq!(body["version"], json!(env!("CARGO_PKG_VERSION"))); + assert_eq!( + body["bootstrap_protocol"], + json!(crate::bootstrap::BOOTSTRAP_PROTOCOL_VERSION) + ); + assert!( + body["instance_id"] + .as_str() + .is_some_and(|value| !value.is_empty()) + ); +} + +#[tokio::test] +async fn healthz_rejects_a_different_persistent_gateway_fingerprint() { + let app = router_with_state(AppState::new_with_bootstrap( + test_config(), + Some("expected-fingerprint".into()), + Some(BootstrapChallengeKey::from_bytes(b"test challenge key")), + false, + None, + )); + let response = app + .oneshot( + Request::builder() + .method("GET") + .uri("/healthz") + .header( + "x-nemo-relay-bootstrap-fingerprint", + "different-fingerprint", + ) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::CONFLICT); + let bytes = response.into_body().collect().await.unwrap().to_bytes(); + let body: Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(body["status"], json!("incompatible")); + assert!(body.get("bootstrap_fingerprint").is_none()); +} + +#[tokio::test] +async fn managed_sidecar_requires_private_client_proof_for_forwarded_credentials() { + let key = BootstrapChallengeKey::from_bytes(b"test challenge key"); + let state = AppState::new_with_bootstrap( + test_config(), + Some("expected-fingerprint".into()), + Some(key.clone()), + true, + None, + ); + let mut headers = HeaderMap::new(); + assert!(!state.allows_environment_provider_auth(&headers)); + headers.insert( + crate::configuration::BOOTSTRAP_CLIENT_TOKEN_HEADER, + HeaderValue::from_static("hmac-sha256:wrong"), + ); + assert!(!state.allows_environment_provider_auth(&headers)); + headers.insert( + crate::configuration::BOOTSTRAP_CLIENT_TOKEN_HEADER, + HeaderValue::from_str(&key.client_token()).unwrap(), + ); + assert!(state.allows_environment_provider_auth(&headers)); + + let foreground = AppState::new(test_config()); + assert!(foreground.allows_environment_provider_auth(&HeaderMap::new())); + + let transparent = AppState::new_with_bootstrap( + test_config(), + Some("transparent-fingerprint".into()), + Some(BootstrapChallengeKey::from_bytes(b"test challenge key")), + false, + None, + ); + assert!(transparent.allows_environment_provider_auth(&HeaderMap::new())); +} + +#[tokio::test] +async fn healthz_only_refreshes_idle_activity_for_an_authenticated_heartbeat() { + let challenge_key = BootstrapChallengeKey::from_bytes(b"test challenge key"); + let state = AppState::new_with_bootstrap( + test_config(), + Some("expected-fingerprint".into()), + Some(challenge_key.clone()), + true, + None, + ); + let activity = state.last_activity.clone(); + let baseline = std::time::Instant::now() - Duration::from_secs(30); + *activity.lock().unwrap() = baseline; + let app = router_with_state(state); + + for fingerprint in [ + None, + Some("wrong-fingerprint"), + Some("expected-fingerprint"), + ] { + let mut request = Request::builder().method("GET").uri("/healthz"); + if let Some(fingerprint) = fingerprint { + request = request.header("x-nemo-relay-bootstrap-fingerprint", fingerprint); + } + let _ = app + .clone() + .oneshot(request.body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(*activity.lock().unwrap(), baseline); + } + + let response = app + .oneshot( + Request::builder() + .method("GET") + .uri("/healthz") + .header("x-nemo-relay-bootstrap-fingerprint", "expected-fingerprint") + .header( + "x-nemo-relay-bootstrap-nonce", + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + ) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response + .headers() + .get("x-nemo-relay-bootstrap-proof") + .unwrap(), + challenge_key + .proof( + "expected-fingerprint", + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + ) + .as_str() + ); + assert!(*activity.lock().unwrap() > baseline); +} + +#[tokio::test] +async fn bootstrap_shutdown_requires_the_private_owner_token() { + let (sender, receiver) = oneshot::channel(); + let app = router_with_state(AppState::new_with_bootstrap( + test_config(), + None, + None, + false, + Some(BootstrapShutdown { + token: "private-token".into(), + sender: Arc::new(std::sync::Mutex::new(Some(sender))), + }), + )); + let rejected = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/bootstrap/shutdown") + .header("x-nemo-relay-bootstrap-token", "wrong-token") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(rejected.status(), StatusCode::FORBIDDEN); + + let accepted = app + .oneshot( + Request::builder() + .method("POST") + .uri("/bootstrap/shutdown") + .header("x-nemo-relay-bootstrap-token", "private-token") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(accepted.status(), StatusCode::NO_CONTENT); + tokio::time::timeout(std::time::Duration::from_secs(1), receiver) + .await + .expect("shutdown signal was not delivered") + .unwrap(); +} + +#[test] +fn readiness_file_is_published_atomically_with_gateway_identity() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("gateway.ready.json"); + let address = "127.0.0.1:43123".parse().unwrap(); + + write_ready_file(&path, address, "test-instance").unwrap(); + + let ready: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); + assert_eq!(ready["address"], json!(address)); + assert_eq!(ready["service"], json!("nemo-relay")); + assert_eq!(ready["version"], json!(env!("CARGO_PKG_VERSION"))); + assert_eq!( + ready["bootstrap_protocol"], + json!(crate::bootstrap::BOOTSTRAP_PROTOCOL_VERSION) + ); + assert_eq!(ready["instance_id"], json!("test-instance")); + assert!(!path.with_extension("json.tmp").exists()); } #[tokio::test] @@ -462,20 +670,20 @@ async fn plugin_idle_timeout_parses_absent_invalid_zero_and_positive_values() { let key = "NEMO_RELAY_PLUGIN_IDLE_TIMEOUT_SECS"; let removed = EnvVarGuard::remove(key); - assert_eq!(plugin_idle_timeout(), None); + assert_eq!(plugin_idle_timeout().unwrap(), None); drop(removed); let invalid = EnvVarGuard::set(key, "not-a-number"); - assert_eq!(plugin_idle_timeout(), None); + assert!(plugin_idle_timeout().is_err()); drop(invalid); let zero = EnvVarGuard::set(key, "0"); - assert_eq!(plugin_idle_timeout(), None); + assert!(plugin_idle_timeout().is_err()); drop(zero); let positive = EnvVarGuard::set(key, "2"); assert_eq!( - plugin_idle_timeout(), + plugin_idle_timeout().unwrap(), Some(std::time::Duration::from_secs(2)) ); drop(positive); @@ -537,6 +745,36 @@ async fn serve_listener_waits_for_active_turn_before_plugin_idle_shutdown() { result.unwrap(); } +#[tokio::test] +async fn idle_shutdown_rechecks_activity_after_session_lookup() { + let timeout = std::time::Duration::from_secs(1); + let last_activity = Arc::new(std::sync::Mutex::new( + std::time::Instant::now() - timeout - std::time::Duration::from_millis(1), + )); + let activity_during_lookup = Arc::clone(&last_activity); + + let ready = idle_shutdown_ready(&last_activity, timeout, async move { + *activity_during_lookup.lock().unwrap() = std::time::Instant::now(); + false + }) + .await; + + assert!(!ready, "new activity must cancel a stale shutdown decision"); +} + +#[tokio::test] +async fn idle_shutdown_requires_expiry_and_no_open_session_without_new_activity() { + let timeout = std::time::Duration::from_secs(1); + let recent = Arc::new(std::sync::Mutex::new(std::time::Instant::now())); + assert!(!idle_shutdown_ready(&recent, timeout, async { false }).await); + + let expired = Arc::new(std::sync::Mutex::new( + std::time::Instant::now() - timeout - std::time::Duration::from_millis(1), + )); + assert!(!idle_shutdown_ready(&expired, timeout, async { true }).await); + assert!(idle_shutdown_ready(&expired, timeout, async { false }).await); +} + #[tokio::test] async fn serve_listener_exits_after_codex_stop_without_session_end() { let _guard = PLUGIN_CONFIG_TEST_LOCK.lock().await; @@ -569,6 +807,37 @@ async fn serve_listener_exits_after_codex_stop_without_session_end() { result.unwrap(); } +#[tokio::test] +async fn serve_listener_exits_after_hermes_turn_without_session_finalize() { + let _guard = PLUGIN_CONFIG_TEST_LOCK.lock().await; + let _env = EnvVarGuard::set("NEMO_RELAY_PLUGIN_IDLE_TIMEOUT_SECS", "1"); + + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let url = format!("http://{address}"); + let handle = tokio::spawn(async move { serve_listener(listener, test_config(), None).await }); + let client = test_http_client(); + + for hook_event_name in ["on_session_start", "on_session_end"] { + let response = client + .post(format!("{url}/hooks/hermes")) + .json(&json!({ + "session_id": "plugin-idle-hermes-session", + "hook_event_name": hook_event_name + })) + .send() + .await + .unwrap(); + assert!(response.status().is_success()); + } + + let result = tokio::time::timeout(std::time::Duration::from_secs(3), handle) + .await + .expect("plugin idle timeout should stop after the Hermes turn ends") + .unwrap(); + result.unwrap(); +} + #[tokio::test] async fn serve_listener_activates_plugin_config_and_clears_on_shutdown() { let _guard = PLUGIN_CONFIG_TEST_LOCK.lock().await; @@ -1653,6 +1922,8 @@ async fn serve_listener_records_codex_stop_atof_contract() { assert_eq!(turn_start["metadata"]["turn_source"], "user_prompt"); assert_eq!(turn_end["data"]["hook_event_name"], "Stop"); assert_eq!(turn_end["data"]["response"], "Done."); + assert_eq!(turn_end["metadata"]["hook_event_name"], "Stop"); + assert_eq!(turn_end["metadata"]["session_id"], "codex-atof-session"); let tool_start = find_scope_event(&events, "Read", "tool", "start"); let tool_end = find_scope_event(&events, "Read", "tool", "end"); @@ -1886,9 +2157,11 @@ async fn serve_listener_with_dynamic_reports_native_load_errors() { vec![ActiveDynamicPluginComponent { plugin_id: "cli.missing-native".into(), kind: DynamicPluginKind::RustDynamic, + lifecycle_generation: 0, manifest_ref: Some(manifest_ref.to_string_lossy().into_owned()), environment_ref: None, config: Map::new(), + activation_snapshot: None, }], Some(shutdown_rx), ) diff --git a/crates/cli/tests/coverage/session_tests.rs b/crates/cli/tests/coverage/shared/session_tests.rs similarity index 94% rename from crates/cli/tests/coverage/session_tests.rs rename to crates/cli/tests/coverage/shared/session_tests.rs index 938cd296d..028245f96 100644 --- a/crates/cli/tests/coverage/session_tests.rs +++ b/crates/cli/tests/coverage/shared/session_tests.rs @@ -7,7 +7,7 @@ use nemo_relay::api::runtime::EventSubscriberFn; use nemo_relay::api::subscriber::{deregister_subscriber, flush_subscribers, register_subscriber}; use nemo_relay::observability::atof::{AtofExporter, AtofExporterConfig, AtofExporterMode}; use nemo_relay::observability::openinference::OpenInferenceSubscriber; -use nemo_relay::plugin::{PluginConfig, clear_plugin_configuration, initialize_plugins}; +use nemo_relay::plugin::{PluginConfig, clear_plugin_configuration, initialize_plugins_exact}; use opentelemetry::KeyValue; use opentelemetry_sdk::trace::InMemorySpanExporterBuilder; use serde_json::json; @@ -16,7 +16,7 @@ use std::path::Path; use std::sync::{Arc, Mutex as StdMutex}; use super::*; -use crate::model::{LlmEvent, LlmHintEvent, SessionEvent, ToolEvent}; +use crate::events::{LlmEvent, LlmHintEvent, SessionEvent, ToolEvent}; use crate::test_support::PLUGIN_CONFIG_TEST_LOCK; const HERMES_ROUTED_TEST_SESSION_KEY: &str = "hermes_routed_test_session_id"; @@ -116,7 +116,63 @@ async fn install_test_atif_plugin(output_directory: &Path) { ] })) .unwrap(); - initialize_plugins(config).await.unwrap(); + initialize_plugins_exact(config).await.unwrap(); +} + +#[tokio::test] +async fn atif_test_plugin_ignores_discovered_atof_configuration() { + let _guard = PLUGIN_CONFIG_TEST_LOCK.lock().await; + let temp = tempfile::tempdir().unwrap(); + let project_config = temp.path().join(".nemo-relay/plugins.toml"); + std::fs::create_dir_all(project_config.parent().unwrap()).unwrap(); + std::fs::write( + &project_config, + r#"version = 1 + +[[components]] +kind = "observability" +enabled = true + +[components.config] +version = 1 + +[components.config.atof] +enabled = true +"#, + ) + .unwrap(); + let _cwd = crate::test_support::CwdTestScope::enter(temp.path()); + let atif_dir = temp.path().join("atif"); + install_test_atif_plugin(&atif_dir).await; + let manager = SessionManager::new(session_test_config()); + manager + .apply_events( + &HeaderMap::new(), + vec![ + NormalizedEvent::AgentStarted(session_event("hermetic-atif", "SessionStart")), + NormalizedEvent::PromptSubmitted(session_event( + "hermetic-atif", + "UserPromptSubmit", + )), + NormalizedEvent::AgentEnded(session_event("hermetic-atif", "SessionEnd")), + ], + ) + .await + .unwrap(); + let _trajectory = read_atif_for_session(&atif_dir, "hermetic-atif"); + clear_plugin_configuration().unwrap(); + + let leaked = std::fs::read_dir(temp.path()) + .unwrap() + .filter_map(Result::ok) + .map(|entry| entry.file_name()) + .filter_map(|name| name.into_string().ok()) + .filter(|name| name.starts_with("nemo-relay-events-") && name.ends_with(".jsonl")) + .collect::>(); + assert!( + leaked.is_empty(), + "test plugin setup must not activate ambient ATOF exporters: {leaked:?}" + ); } fn make_atof_test_exporter(output_directory: &Path, filename: &str) -> AtofExporter { @@ -210,7 +266,7 @@ fn register_filtered_session_subscriber( } async fn apply_codex_payload(manager: &SessionManager, headers: &HeaderMap, payload: Value) { - let outcome = crate::adapters::codex::adapt(payload, headers); + let outcome = crate::agents::shared::adapters::codex::adapt(payload, headers); manager.apply_events(headers, outcome.events).await.unwrap(); } @@ -567,7 +623,7 @@ async fn drive_hermes_orphan_subagent_stop( "session_id": session_id }), ] { - let outcome = crate::adapters::hermes::adapt(payload, headers); + let outcome = crate::agents::shared::adapters::hermes::adapt(payload, headers); manager.apply_events(headers, outcome.events).await.unwrap(); } } @@ -655,7 +711,7 @@ async fn drive_hermes_subagent_child_session( "session_id": parent_session_id }), ] { - let outcome = crate::adapters::hermes::adapt(payload, headers); + let outcome = crate::agents::shared::adapters::hermes::adapt(payload, headers); manager.apply_events(headers, outcome.events).await.unwrap(); } } @@ -669,8 +725,8 @@ async fn nests_agent_subagent_and_tool_lifecycle() { anthropic_base_url: "http://127.0.0.1".into(), metadata: None, plugin_config: None, - max_hook_payload_bytes: crate::config::DEFAULT_MAX_HOOK_PAYLOAD_BYTES, - max_passthrough_body_bytes: crate::config::DEFAULT_MAX_PASSTHROUGH_BODY_BYTES, + max_hook_payload_bytes: crate::configuration::DEFAULT_MAX_HOOK_PAYLOAD_BYTES, + max_passthrough_body_bytes: crate::configuration::DEFAULT_MAX_PASSTHROUGH_BODY_BYTES, }; let manager = SessionManager::new(config); let headers = HeaderMap::new(); @@ -962,6 +1018,121 @@ async fn turn_output_uses_last_root_owned_llm_response() { deregister_subscriber(subscriber_name).unwrap(); } +#[tokio::test] +async fn turn_end_metadata_comes_only_from_the_real_turn_boundary() { + let subscriber_name = "cli-turn-boundary-metadata-test"; + let _ = deregister_subscriber(subscriber_name); + let captured = Arc::new(StdMutex::new(HashMap::::new())); + let events = captured.clone(); + register_subscriber( + subscriber_name, + Arc::new(move |event| { + if event.scope_category() != Some(ScopeCategory::End) || event.name() != "codex-turn" { + return; + } + let Some(session_id) = event + .metadata() + .and_then(|metadata| metadata.get("session_id")) + .and_then(Value::as_str) + else { + return; + }; + events.lock().unwrap().insert( + session_id.to_string(), + ( + event.output().cloned().unwrap_or(Value::Null), + event.metadata().cloned().unwrap_or(Value::Null), + ), + ); + }), + ) + .unwrap(); + + let manager = SessionManager::new(session_test_config()); + for session_id in [ + "explicit-turn-end", + "fallback-turn-end", + "shutdown-turn-end", + ] { + manager + .apply_events( + &HeaderMap::new(), + vec![ + NormalizedEvent::AgentStarted(codex_session_event( + session_id, + "SessionStart", + json!({ "session_id": session_id }), + )), + NormalizedEvent::PromptSubmitted(codex_session_event( + session_id, + "UserPromptSubmit", + json!({ "session_id": session_id }), + )), + ], + ) + .await + .unwrap(); + } + let llm = manager + .start_llm( + &HeaderMap::new(), + LlmGatewayStart { + session_id: Some("explicit-turn-end".into()), + ..llm_start() + }, + ) + .await + .unwrap(); + manager + .end_llm(llm, json!({ "message": "pong" }), json!({})) + .await + .unwrap(); + manager + .apply_events( + &HeaderMap::new(), + vec![NormalizedEvent::TurnEnded(codex_session_event( + "explicit-turn-end", + "Stop", + json!({ + "session_id": "explicit-turn-end", + "hook_event_name": "Stop", + "boundary_processed": true + }), + ))], + ) + .await + .unwrap(); + manager + .apply_events( + &HeaderMap::new(), + vec![NormalizedEvent::AgentEnded(codex_session_event( + "fallback-turn-end", + "SessionEnd", + json!({ + "session_id": "fallback-turn-end", + "boundary_processed": "must-not-leak" + }), + ))], + ) + .await + .unwrap(); + manager.close_all("gateway_shutdown").await.unwrap(); + + flush_subscribers().unwrap(); + let captured = captured.lock().unwrap(); + let (output, metadata) = captured.get("explicit-turn-end").unwrap(); + assert_eq!(output, &json!({ "message": "pong" })); + assert_eq!(metadata["hook_event_name"], "Stop"); + assert_eq!(metadata["boundary_processed"], true); + let (_, fallback_metadata) = captured.get("fallback-turn-end").unwrap(); + assert!(fallback_metadata.get("boundary_processed").is_none()); + let (shutdown_output, shutdown_metadata) = captured.get("shutdown-turn-end").unwrap(); + assert_eq!(shutdown_output["status"], "gateway_shutdown"); + assert!(shutdown_metadata.get("boundary_processed").is_none()); + drop(captured); + deregister_subscriber(subscriber_name).unwrap(); +} + #[tokio::test] async fn new_subagent_claims_first_unhinted_llm_when_siblings_active() { let manager = SessionManager::new(session_test_config()); @@ -1455,7 +1626,7 @@ async fn hermes_subagent_start_does_not_reparent_active_child_session() { } }), ] { - let outcome = crate::adapters::hermes::adapt(payload, &headers); + let outcome = crate::agents::shared::adapters::hermes::adapt(payload, &headers); manager .apply_events(&headers, outcome.events) .await @@ -1806,8 +1977,8 @@ async fn writes_atif_on_session_end_from_plugin_config() { anthropic_base_url: "http://127.0.0.1".into(), metadata: None, plugin_config: None, - max_hook_payload_bytes: crate::config::DEFAULT_MAX_HOOK_PAYLOAD_BYTES, - max_passthrough_body_bytes: crate::config::DEFAULT_MAX_PASSTHROUGH_BODY_BYTES, + max_hook_payload_bytes: crate::configuration::DEFAULT_MAX_HOOK_PAYLOAD_BYTES, + max_passthrough_body_bytes: crate::configuration::DEFAULT_MAX_PASSTHROUGH_BODY_BYTES, }; let manager = SessionManager::new(config); let mut headers = HeaderMap::new(); @@ -2065,8 +2236,8 @@ async fn duplicate_agent_end_does_not_overwrite_atif_with_empty_session() { anthropic_base_url: "http://127.0.0.1".into(), metadata: None, plugin_config: None, - max_hook_payload_bytes: crate::config::DEFAULT_MAX_HOOK_PAYLOAD_BYTES, - max_passthrough_body_bytes: crate::config::DEFAULT_MAX_PASSTHROUGH_BODY_BYTES, + max_hook_payload_bytes: crate::configuration::DEFAULT_MAX_HOOK_PAYLOAD_BYTES, + max_passthrough_body_bytes: crate::configuration::DEFAULT_MAX_PASSTHROUGH_BODY_BYTES, }; let manager = SessionManager::new(config); let headers = HeaderMap::new(); @@ -2145,8 +2316,8 @@ async fn writes_hermes_api_hook_usage_to_atif_metrics() { anthropic_base_url: "http://127.0.0.1".into(), metadata: None, plugin_config: None, - max_hook_payload_bytes: crate::config::DEFAULT_MAX_HOOK_PAYLOAD_BYTES, - max_passthrough_body_bytes: crate::config::DEFAULT_MAX_PASSTHROUGH_BODY_BYTES, + max_hook_payload_bytes: crate::configuration::DEFAULT_MAX_HOOK_PAYLOAD_BYTES, + max_passthrough_body_bytes: crate::configuration::DEFAULT_MAX_PASSTHROUGH_BODY_BYTES, }; let manager = SessionManager::new(config); let headers = HeaderMap::new(); @@ -2228,8 +2399,8 @@ async fn writes_hermes_api_hook_reported_cost_to_atif_metrics() { anthropic_base_url: "http://127.0.0.1".into(), metadata: None, plugin_config: None, - max_hook_payload_bytes: crate::config::DEFAULT_MAX_HOOK_PAYLOAD_BYTES, - max_passthrough_body_bytes: crate::config::DEFAULT_MAX_PASSTHROUGH_BODY_BYTES, + max_hook_payload_bytes: crate::configuration::DEFAULT_MAX_HOOK_PAYLOAD_BYTES, + max_passthrough_body_bytes: crate::configuration::DEFAULT_MAX_PASSTHROUGH_BODY_BYTES, }; let manager = SessionManager::new(config); let headers = HeaderMap::new(); @@ -2358,7 +2529,7 @@ async fn hermes_exact_api_hooks_write_atif_request_response_and_cost() { "session_id": "hermes-exact-atif" }), ] { - let outcome = crate::adapters::hermes::adapt(payload, &headers); + let outcome = crate::agents::shared::adapters::hermes::adapt(payload, &headers); manager .apply_events(&headers, outcome.events) .await @@ -2462,7 +2633,7 @@ async fn hermes_api_request_error_writes_atif_error_step_and_fidelity() { "session_id": "hermes-error" }), ] { - let outcome = crate::adapters::hermes::adapt(payload, &headers); + let outcome = crate::agents::shared::adapters::hermes::adapt(payload, &headers); manager .apply_events(&headers, outcome.events) .await @@ -2573,7 +2744,7 @@ async fn hermes_lossy_api_hooks_write_atif_fidelity_markers() { "session_id": "hermes-lossy-atif" }), ] { - let outcome = crate::adapters::hermes::adapt(payload, &headers); + let outcome = crate::agents::shared::adapters::hermes::adapt(payload, &headers); manager .apply_events(&headers, outcome.events) .await @@ -2640,7 +2811,7 @@ async fn hermes_uncorrelatable_pre_tool_call_does_not_create_shutdown_trajectory "session_id": "hermes-main" }), ] { - let outcome = crate::adapters::hermes::adapt(payload, &headers); + let outcome = crate::agents::shared::adapters::hermes::adapt(payload, &headers); manager .apply_events(&headers, outcome.events) .await @@ -2720,7 +2891,7 @@ async fn hermes_turn_end_snapshots_atif_without_boundary_system_step() { "session_id": "hermes-clean" }), ] { - let outcome = crate::adapters::hermes::adapt(payload, &headers); + let outcome = crate::agents::shared::adapters::hermes::adapt(payload, &headers); manager .apply_events(&headers, outcome.events) .await @@ -2775,14 +2946,14 @@ async fn hermes_task_id_tool_hooks_reuse_api_session() { } }), ] { - let outcome = crate::adapters::hermes::adapt(payload, &headers); + let outcome = crate::agents::shared::adapters::hermes::adapt(payload, &headers); manager .apply_events(&headers, outcome.events) .await .unwrap(); } - let pre_tool = crate::adapters::hermes::adapt( + let pre_tool = crate::agents::shared::adapters::hermes::adapt( json!({ "hook_event_name": "pre_tool_call", "session_id": "hermes-main", @@ -2814,7 +2985,7 @@ async fn hermes_task_id_tool_hooks_reuse_api_session() { ); } - let post_tool = crate::adapters::hermes::adapt( + let post_tool = crate::agents::shared::adapters::hermes::adapt( json!({ "hook_event_name": "post_tool_call", "session_id": "hermes-main", @@ -2935,7 +3106,7 @@ async fn hermes_post_tool_call_writes_atif_observation_with_source_call_id() { "session_id": "hermes-tool-result" }), ] { - let outcome = crate::adapters::hermes::adapt(payload, &headers); + let outcome = crate::agents::shared::adapters::hermes::adapt(payload, &headers); manager .apply_events(&headers, outcome.events) .await @@ -3534,8 +3705,8 @@ async fn handles_out_of_order_subagent_and_tool_end_events() { anthropic_base_url: "http://127.0.0.1".into(), metadata: None, plugin_config: None, - max_hook_payload_bytes: crate::config::DEFAULT_MAX_HOOK_PAYLOAD_BYTES, - max_passthrough_body_bytes: crate::config::DEFAULT_MAX_PASSTHROUGH_BODY_BYTES, + max_hook_payload_bytes: crate::configuration::DEFAULT_MAX_HOOK_PAYLOAD_BYTES, + max_passthrough_body_bytes: crate::configuration::DEFAULT_MAX_PASSTHROUGH_BODY_BYTES, }; let manager = SessionManager::new(config); let headers = HeaderMap::new(); @@ -3611,8 +3782,8 @@ async fn out_of_order_started_subagent_end_does_not_leak_scope() { anthropic_base_url: "http://127.0.0.1".into(), metadata: None, plugin_config: None, - max_hook_payload_bytes: crate::config::DEFAULT_MAX_HOOK_PAYLOAD_BYTES, - max_passthrough_body_bytes: crate::config::DEFAULT_MAX_PASSTHROUGH_BODY_BYTES, + max_hook_payload_bytes: crate::configuration::DEFAULT_MAX_HOOK_PAYLOAD_BYTES, + max_passthrough_body_bytes: crate::configuration::DEFAULT_MAX_PASSTHROUGH_BODY_BYTES, }; let manager = SessionManager::new(config); let headers = HeaderMap::new(); @@ -3684,8 +3855,8 @@ async fn agent_end_closes_nested_active_subagents_lifo() { anthropic_base_url: "http://127.0.0.1".into(), metadata: None, plugin_config: None, - max_hook_payload_bytes: crate::config::DEFAULT_MAX_HOOK_PAYLOAD_BYTES, - max_passthrough_body_bytes: crate::config::DEFAULT_MAX_PASSTHROUGH_BODY_BYTES, + max_hook_payload_bytes: crate::configuration::DEFAULT_MAX_HOOK_PAYLOAD_BYTES, + max_passthrough_body_bytes: crate::configuration::DEFAULT_MAX_PASSTHROUGH_BODY_BYTES, }; let manager = SessionManager::new(config); let headers = HeaderMap::new(); @@ -3741,8 +3912,8 @@ async fn llm_lifecycle_starts_implicit_gateway_session() { anthropic_base_url: "http://127.0.0.1".into(), metadata: None, plugin_config: None, - max_hook_payload_bytes: crate::config::DEFAULT_MAX_HOOK_PAYLOAD_BYTES, - max_passthrough_body_bytes: crate::config::DEFAULT_MAX_PASSTHROUGH_BODY_BYTES, + max_hook_payload_bytes: crate::configuration::DEFAULT_MAX_HOOK_PAYLOAD_BYTES, + max_passthrough_body_bytes: crate::configuration::DEFAULT_MAX_PASSTHROUGH_BODY_BYTES, }; let manager = SessionManager::new(config); let active = manager @@ -3855,7 +4026,7 @@ async fn claude_startup_probe_does_not_open_null_input_turn() { json!("claude_startup_probe") ); manager - .finish_gateway_call(&prep.session_id, prep.prune_empty_session_on_finish) + .finish_gateway_call(&prep.session_id, prep.session_finish) .await; manager @@ -3904,11 +4075,11 @@ async fn claude_startup_probe_only_session_is_pruned_after_finish() { .unwrap(); assert!(prep.bypass_managed_pipeline); - assert!(prep.prune_empty_session_on_finish); + assert_eq!(prep.session_finish, GatewaySessionFinish::PruneIfEmpty); assert!(manager.inner.lock().await.contains_key("probe-only")); manager - .finish_gateway_call(&prep.session_id, prep.prune_empty_session_on_finish) + .finish_gateway_call(&prep.session_id, prep.session_finish) .await; assert!(!manager.inner.lock().await.contains_key("probe-only")); @@ -3996,7 +4167,7 @@ async fn claude_direct_gateway_request_seeds_turn_input_before_prompt_hook() { .unwrap(); assert!(!prep.bypass_managed_pipeline); manager - .finish_gateway_call(&prep.session_id, prep.prune_empty_session_on_finish) + .finish_gateway_call(&prep.session_id, prep.session_finish) .await; manager @@ -4225,8 +4396,8 @@ async fn llm_lifecycle_uses_single_active_hook_session_when_header_is_missing() anthropic_base_url: "http://127.0.0.1".into(), metadata: None, plugin_config: None, - max_hook_payload_bytes: crate::config::DEFAULT_MAX_HOOK_PAYLOAD_BYTES, - max_passthrough_body_bytes: crate::config::DEFAULT_MAX_PASSTHROUGH_BODY_BYTES, + max_hook_payload_bytes: crate::configuration::DEFAULT_MAX_HOOK_PAYLOAD_BYTES, + max_passthrough_body_bytes: crate::configuration::DEFAULT_MAX_PASSTHROUGH_BODY_BYTES, }; let manager = SessionManager::new(config); manager @@ -4274,6 +4445,76 @@ async fn llm_lifecycle_uses_single_active_hook_session_when_header_is_missing() assert!(!sessions.contains_key("gateway-gateway")); } +#[tokio::test] +async fn unidentified_concurrent_gateway_calls_use_isolated_ephemeral_sessions() { + let manager = SessionManager::new(session_test_config()); + manager + .apply_events( + &HeaderMap::new(), + ["hermes-a", "hermes-b"] + .into_iter() + .map(|session_id| { + NormalizedEvent::AgentStarted(SessionEvent { + session_id: session_id.into(), + agent_kind: AgentKind::Hermes, + event_name: "on_session_start".into(), + payload: json!({}), + metadata: json!({}), + }) + }) + .collect(), + ) + .await + .unwrap(); + + let first = manager + .prepare_gateway_call( + &HeaderMap::new(), + LlmGatewayStart { + session_id: None, + ..llm_start() + }, + ) + .await + .unwrap(); + let second = manager + .prepare_gateway_call( + &HeaderMap::new(), + LlmGatewayStart { + session_id: None, + ..llm_start() + }, + ) + .await + .unwrap(); + + assert_eq!(first.session_finish, GatewaySessionFinish::Close); + assert_eq!(second.session_finish, GatewaySessionFinish::Close); + assert_ne!(first.session_id, second.session_id); + assert!(first.session_id.starts_with("gateway-isolated-")); + assert!(second.session_id.starts_with("gateway-isolated-")); + + manager + .finish_gateway_call(&first.session_id, first.session_finish) + .await; + { + let sessions = manager.inner.lock().await; + assert!(!sessions.contains_key(&first.session_id)); + assert!(sessions.contains_key(&second.session_id)); + assert!(sessions.contains_key("hermes-a")); + assert!(sessions.contains_key("hermes-b")); + } + + manager + .finish_gateway_call(&second.session_id, second.session_finish) + .await; + assert!(!manager.has_open_sessions().await); + let sessions = manager.inner.lock().await; + assert!(!sessions.contains_key(&second.session_id)); + assert!(sessions.contains_key("hermes-a")); + assert!(sessions.contains_key("hermes-b")); +} + #[tokio::test] async fn single_pending_llm_hint_claims_next_gateway_llm() { let config = GatewayConfig { @@ -4283,8 +4524,8 @@ async fn single_pending_llm_hint_claims_next_gateway_llm() { anthropic_base_url: "http://127.0.0.1".into(), metadata: None, plugin_config: None, - max_hook_payload_bytes: crate::config::DEFAULT_MAX_HOOK_PAYLOAD_BYTES, - max_passthrough_body_bytes: crate::config::DEFAULT_MAX_PASSTHROUGH_BODY_BYTES, + max_hook_payload_bytes: crate::configuration::DEFAULT_MAX_HOOK_PAYLOAD_BYTES, + max_passthrough_body_bytes: crate::configuration::DEFAULT_MAX_PASSTHROUGH_BODY_BYTES, }; let manager = SessionManager::new(config); manager @@ -4381,8 +4622,8 @@ async fn multiple_llm_hints_resolve_by_generation_id() { anthropic_base_url: "http://127.0.0.1".into(), metadata: None, plugin_config: None, - max_hook_payload_bytes: crate::config::DEFAULT_MAX_HOOK_PAYLOAD_BYTES, - max_passthrough_body_bytes: crate::config::DEFAULT_MAX_PASSTHROUGH_BODY_BYTES, + max_hook_payload_bytes: crate::configuration::DEFAULT_MAX_HOOK_PAYLOAD_BYTES, + max_passthrough_body_bytes: crate::configuration::DEFAULT_MAX_PASSTHROUGH_BODY_BYTES, }; let manager = SessionManager::new(config); manager @@ -4497,8 +4738,8 @@ async fn ambiguous_llm_hints_fall_back_to_agent_scope() { anthropic_base_url: "http://127.0.0.1".into(), metadata: None, plugin_config: None, - max_hook_payload_bytes: crate::config::DEFAULT_MAX_HOOK_PAYLOAD_BYTES, - max_passthrough_body_bytes: crate::config::DEFAULT_MAX_PASSTHROUGH_BODY_BYTES, + max_hook_payload_bytes: crate::configuration::DEFAULT_MAX_HOOK_PAYLOAD_BYTES, + max_passthrough_body_bytes: crate::configuration::DEFAULT_MAX_PASSTHROUGH_BODY_BYTES, }; let manager = SessionManager::new(config); manager @@ -4591,8 +4832,8 @@ async fn no_active_hint_reuses_last_llm_owner() { anthropic_base_url: "http://127.0.0.1".into(), metadata: None, plugin_config: None, - max_hook_payload_bytes: crate::config::DEFAULT_MAX_HOOK_PAYLOAD_BYTES, - max_passthrough_body_bytes: crate::config::DEFAULT_MAX_PASSTHROUGH_BODY_BYTES, + max_hook_payload_bytes: crate::configuration::DEFAULT_MAX_HOOK_PAYLOAD_BYTES, + max_passthrough_body_bytes: crate::configuration::DEFAULT_MAX_PASSTHROUGH_BODY_BYTES, }; let manager = SessionManager::new(config); manager @@ -5727,7 +5968,9 @@ async fn idle_timeout_waits_for_active_gateway_llm_call() { .contains_key("active-gateway-call") ); - manager.finish_gateway_call(&prep.session_id, false).await; + manager + .finish_gateway_call(&prep.session_id, GatewaySessionFinish::Retain) + .await; let closed = manager .close_idle_sessions_at( Instant::now() + AGENT_IDLE_TIMEOUT + Duration::from_secs(1), @@ -5741,6 +5984,32 @@ async fn idle_timeout_waits_for_active_gateway_llm_call() { assert!(manager.inner.lock().await.is_empty()); } +#[tokio::test] +async fn a_single_stale_retained_session_is_not_used_for_headerless_calls() { + let manager = SessionManager::new(session_test_config()); + manager + .apply_events( + &HeaderMap::new(), + vec![NormalizedEvent::AgentStarted(session_event( + "stale-session", + "SessionStart", + ))], + ) + .await + .unwrap(); + let mut sessions = manager.inner.lock().await; + sessions.get_mut("stale-session").unwrap().last_activity = + Instant::now() - AGENT_IDLE_TIMEOUT - Duration::from_secs(1); + + assert_eq!(single_active_session_id(&sessions), None); +} + +#[test] +fn weak_subagent_start_status_does_not_teach_request_affinity() { + assert!(!owner_status_teaches_request_affinity("subagent_start")); + assert!(owner_status_teaches_request_affinity("active_subagent")); +} + #[tokio::test] async fn gateway_shutdown_attempts_remaining_sessions_after_close_error() { let subscriber_name = "cli-close-all-deferred-error-test"; @@ -6393,8 +6662,8 @@ fn session_test_config() -> GatewayConfig { anthropic_base_url: "http://127.0.0.1".into(), metadata: None, plugin_config: None, - max_hook_payload_bytes: crate::config::DEFAULT_MAX_HOOK_PAYLOAD_BYTES, - max_passthrough_body_bytes: crate::config::DEFAULT_MAX_PASSTHROUGH_BODY_BYTES, + max_hook_payload_bytes: crate::configuration::DEFAULT_MAX_HOOK_PAYLOAD_BYTES, + max_passthrough_body_bytes: crate::configuration::DEFAULT_MAX_PASSTHROUGH_BODY_BYTES, } } @@ -6408,8 +6677,8 @@ async fn turn_ended_is_noop_without_active_turn_scope() { anthropic_base_url: "http://127.0.0.1".into(), metadata: None, plugin_config: None, - max_hook_payload_bytes: crate::config::DEFAULT_MAX_HOOK_PAYLOAD_BYTES, - max_passthrough_body_bytes: crate::config::DEFAULT_MAX_PASSTHROUGH_BODY_BYTES, + max_hook_payload_bytes: crate::configuration::DEFAULT_MAX_HOOK_PAYLOAD_BYTES, + max_passthrough_body_bytes: crate::configuration::DEFAULT_MAX_PASSTHROUGH_BODY_BYTES, }; let manager = SessionManager::new(config); manager diff --git a/crates/cli/tests/coverage/setup_tests.rs b/crates/cli/tests/coverage/shared/setup_tests.rs similarity index 82% rename from crates/cli/tests/coverage/setup_tests.rs rename to crates/cli/tests/coverage/shared/setup_tests.rs index aed26f987..0710bea1d 100644 --- a/crates/cli/tests/coverage/setup_tests.rs +++ b/crates/cli/tests/coverage/shared/setup_tests.rs @@ -4,7 +4,6 @@ use super::*; use crate::test_support::CwdTestScope as CwdScope; use std::ffi::OsString; -use std::path::PathBuf; // Tests that exercise the global-config write path clear `$XDG_CONFIG_HOME` // because CI runners commonly set it to a real `/home/runner/.config` path. @@ -82,10 +81,8 @@ impl Drop for EnvScope { } } -// Stub-binary detection relies on the Unix executable bit. Windows-side agent presence checks -// use a different mechanism (e.g. `.exe` extension matching), so this lookup test is gated to -// Unix to keep cross-platform CI green; covering the Windows code path is left to a separate -// test once the launcher grows real Windows support. +// This stub-binary test specifically verifies Unix executable-bit handling. Platform-neutral +// PATH/PATHEXT resolution and Windows command-shim execution have separate focused coverage. #[cfg(unix)] #[test] fn detect_installed_agents_finds_binaries_on_path() { @@ -118,7 +115,6 @@ fn build_config_does_not_emit_observability_exporters() { let answers = SetupAnswers { scope: ConfigScope::Project, agents: vec![], - hermes_hooks_path: None, }; let rendered = build_config(&answers).to_string(); @@ -135,7 +131,6 @@ fn build_config_skips_empty_sections_when_no_backends_selected() { let answers = SetupAnswers { scope: ConfigScope::Project, agents: vec![], - hermes_hooks_path: None, }; let doc = build_config(&answers); @@ -152,7 +147,6 @@ fn build_config_emits_agents_block_with_user_facing_keys() { let answers = SetupAnswers { scope: ConfigScope::Project, agents: vec![CodingAgent::ClaudeCode, CodingAgent::Codex], - hermes_hooks_path: None, }; let doc = build_config(&answers); @@ -170,7 +164,6 @@ fn save_config_writes_project_scope_to_workspace_dir() { let answers = SetupAnswers { scope: ConfigScope::Project, agents: vec![CodingAgent::ClaudeCode], - hermes_hooks_path: None, }; let doc = build_config(&answers); let temp = tempfile::tempdir().unwrap(); @@ -212,7 +205,6 @@ command = "codex --full-auto" let answers = SetupAnswers { scope: ConfigScope::Project, agents: vec![CodingAgent::ClaudeCode], - hermes_hooks_path: None, }; let doc = build_config(&answers); save_config( @@ -255,7 +247,6 @@ fn save_config_writes_both_scopes_when_both_selected() { let answers = SetupAnswers { scope: ConfigScope::Both, agents: vec![], - hermes_hooks_path: None, }; let doc = build_config(&answers); let cwd = tempfile::tempdir().unwrap(); @@ -288,18 +279,6 @@ fn global_config_dir_and_preview_paths_prefer_xdg_when_set() { ); } -#[test] -fn build_config_emits_hooks_path_for_hermes_when_set() { - let answers = SetupAnswers { - scope: ConfigScope::Project, - agents: vec![CodingAgent::Hermes], - hermes_hooks_path: Some(std::path::PathBuf::from("/tmp/proj/.hermes/config.yaml")), - }; - let rendered = build_config(&answers).to_string(); - assert!(rendered.contains("[agents.hermes]")); - assert!(rendered.contains(r#"hooks_path = "/tmp/proj/.hermes/config.yaml""#)); -} - #[test] fn config_scope_labels_are_user_facing_and_stable() { assert!( @@ -319,37 +298,6 @@ fn config_scope_labels_are_user_facing_and_stable() { ); } -#[test] -fn hermes_hook_paths_follow_selected_scope() { - let cwd = PathBuf::from("/workspace"); - let home = PathBuf::from("/home/user"); - let agents = [CodingAgent::Hermes]; - - assert_eq!( - hermes_hooks_path_for_scope(&agents, ConfigScope::Project, &cwd, &home), - Some(PathBuf::from("/workspace/.hermes/config.yaml")) - ); - assert_eq!( - hermes_hooks_path_for_scope(&agents, ConfigScope::Both, &cwd, &home), - Some(PathBuf::from("/workspace/.hermes/config.yaml")) - ); - assert_eq!( - hermes_hooks_path_for_scope(&agents, ConfigScope::Global, &cwd, &home), - Some(PathBuf::from("/home/user/.hermes/config.yaml")) - ); - assert_eq!( - hermes_hooks_path_for_scope(&[], ConfigScope::Project, &cwd, &home), - None - ); - assert_eq!( - hermes_hook_targets(ConfigScope::Both, &cwd, &home), - vec![ - PathBuf::from("/workspace/.hermes/config.yaml"), - PathBuf::from("/home/user/.hermes/config.yaml") - ] - ); -} - #[test] fn existing_defaults_detects_scope_and_agents_from_docs() { let empty = Defaults::default(); @@ -418,33 +366,6 @@ fn read_existing_defaults_prefers_workspace_and_reports_scope_variants() { assert_eq!(defaults.agents, vec![CodingAgent::ClaudeCode]); } -#[test] -fn install_hermes_hooks_writes_yaml_and_merges_existing() { - let cwd = tempfile::tempdir().unwrap(); - let home = tempfile::tempdir().unwrap(); - // Seed an existing hermes config so we can verify the merge preserves user state. - let project_hermes = cwd.path().join(".hermes"); - std::fs::create_dir_all(&project_hermes).unwrap(); - std::fs::write( - project_hermes.join("config.yaml"), - "model:\n provider: auto\n", - ) - .unwrap(); - - let written = install_hermes_hooks(ConfigScope::Both, cwd.path(), home.path()).unwrap(); - - assert_eq!(written.len(), 2); - let project_yaml = std::fs::read_to_string(cwd.path().join(".hermes/config.yaml")).unwrap(); - assert!(project_yaml.contains("nemo-relay hook-forward hermes")); - assert!(project_yaml.contains("api_request_error")); - assert!( - project_yaml.contains("provider: auto"), - "existing model block must survive merge" - ); - let home_yaml = std::fs::read_to_string(home.path().join(".hermes/config.yaml")).unwrap(); - assert!(home_yaml.contains("nemo-relay hook-forward hermes")); -} - #[test] fn write_or_merge_recovers_from_non_table_agents_value() { let temp = tempfile::tempdir().unwrap(); @@ -463,7 +384,6 @@ config = { version = 1, components = [] } let doc = build_config(&SetupAnswers { scope: ConfigScope::Project, agents: vec![CodingAgent::Codex], - hermes_hooks_path: None, }); write_or_merge(&path, &doc, Some(CodingAgent::Codex)).unwrap(); @@ -482,7 +402,6 @@ fn write_or_merge_overwrites_without_merge_scope_and_reports_malformed_existing_ let doc = build_config(&SetupAnswers { scope: ConfigScope::Project, agents: vec![CodingAgent::Hermes], - hermes_hooks_path: Some(temp.path().join(".hermes/config.yaml")), }); write_or_merge(&path, &doc, None).unwrap(); @@ -516,13 +435,13 @@ command = "codex" ) .unwrap(); - reset(Some(CodingAgent::ClaudeCode)).unwrap(); + reset(ConfigScope::Project, Some(CodingAgent::ClaudeCode)).unwrap(); let scoped = std::fs::read_to_string(&path).unwrap(); assert!(!scoped.contains("[agents.claude]")); assert!(scoped.contains("[agents.codex]")); - reset(None).unwrap(); + reset(ConfigScope::Project, None).unwrap(); assert!(!path.exists()); } @@ -536,7 +455,7 @@ fn reset_removes_empty_agents_table_when_last_agent_is_removed() { let path = config_dir.join("config.toml"); std::fs::write(&path, "[agents.codex]\ncommand = \"codex\"\n").unwrap(); - reset(Some(CodingAgent::Codex)).unwrap(); + reset(ConfigScope::Project, Some(CodingAgent::Codex)).unwrap(); let contents = std::fs::read_to_string(&path).unwrap(); assert!(!contents.contains("[agents]")); @@ -548,20 +467,26 @@ fn reset_noops_when_project_config_is_missing() { let temp = tempfile::tempdir().unwrap(); let _cwd = CwdScope::enter(temp.path()); - reset(None).unwrap(); - reset(Some(CodingAgent::Codex)).unwrap(); + reset(ConfigScope::Project, None).unwrap(); + reset(ConfigScope::Project, Some(CodingAgent::Codex)).unwrap(); } #[test] fn reset_reports_missing_or_malformed_agent_blocks_without_rewriting() { let temp = tempfile::tempdir().unwrap(); let _cwd = CwdScope::enter(temp.path()); + let hermes_home = temp.path().join("hermes-home"); + let _env = EnvScope::set(&[ + ("HOME", Some(temp.path().as_os_str())), + ("USERPROFILE", None), + ("HERMES_HOME", Some(hermes_home.as_os_str())), + ]); let config_dir = temp.path().join(".nemo-relay"); std::fs::create_dir_all(&config_dir).unwrap(); let path = config_dir.join("config.toml"); std::fs::write(&path, "agents = \"not-a-table\"\n").unwrap(); - reset(Some(CodingAgent::Hermes)).unwrap(); + reset(ConfigScope::Project, Some(CodingAgent::Hermes)).unwrap(); assert_eq!( std::fs::read_to_string(&path).unwrap(), @@ -569,13 +494,47 @@ fn reset_reports_missing_or_malformed_agent_blocks_without_rewriting() { ); std::fs::write(&path, "not valid toml = [\n").unwrap(); - let error = reset(Some(CodingAgent::Hermes)).unwrap_err().to_string(); + let error = reset(ConfigScope::Project, Some(CodingAgent::Hermes)) + .unwrap_err() + .to_string(); assert!( error.contains("could not parse existing config"), "error was: {error}" ); } +#[test] +fn reset_honors_global_and_both_scopes() { + let temp = tempfile::tempdir().unwrap(); + let project = temp.path().join("project"); + let home = temp.path().join("home"); + let xdg = temp.path().join("xdg"); + std::fs::create_dir_all(&project).unwrap(); + std::fs::create_dir_all(&home).unwrap(); + let _cwd = CwdScope::enter(&project); + let _env = EnvScope::set(&[ + ("HOME", Some(home.as_os_str())), + ("USERPROFILE", Some(home.as_os_str())), + ("XDG_CONFIG_HOME", Some(xdg.as_os_str())), + ]); + + let project_path = project.join(".nemo-relay/config.toml"); + let global_path = global_config_dir(&home).join("config.toml"); + std::fs::create_dir_all(project_path.parent().unwrap()).unwrap(); + std::fs::create_dir_all(global_path.parent().unwrap()).unwrap(); + std::fs::write(&project_path, "[agents.codex]\ncommand = \"codex\"\n").unwrap(); + std::fs::write(&global_path, "[agents.codex]\ncommand = \"codex\"\n").unwrap(); + + reset(ConfigScope::Global, None).unwrap(); + assert!(project_path.exists()); + assert!(!global_path.exists()); + + std::fs::write(&global_path, "[agents.codex]\ncommand = \"codex\"\n").unwrap(); + reset(ConfigScope::Both, None).unwrap(); + assert!(!project_path.exists()); + assert!(!global_path.exists()); +} + #[test] fn plugins_edit_command_for_scope_targets_expected_plugin_scope() { use crate::plugins::config_io::{TargetScope, target_scope}; diff --git a/crates/cli/tests/coverage/shared/test_support.rs b/crates/cli/tests/coverage/shared/test_support.rs new file mode 100644 index 000000000..a76959793 --- /dev/null +++ b/crates/cli/tests/coverage/shared/test_support.rs @@ -0,0 +1,153 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use std::ffi::{OsStr, OsString}; +use std::io::Read; +use std::net::TcpListener; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Mutex, MutexGuard}; +use std::time::{Duration, Instant}; + +#[must_use] +pub(crate) struct CwdTestScope { + _guard: MutexGuard<'static, ()>, + prev: Option, +} + +impl CwdTestScope { + pub(crate) fn locked() -> Self { + Self { + _guard: lock_cwd(), + prev: None, + } + } + + pub(crate) fn enter(path: &Path) -> Self { + let guard = lock_cwd(); + let prev = std::env::current_dir().unwrap(); + std::env::set_current_dir(path).unwrap(); + Self { + _guard: guard, + prev: Some(prev), + } + } +} + +impl Drop for CwdTestScope { + fn drop(&mut self) { + if let Some(prev) = &self.prev + && let Err(error) = std::env::set_current_dir(prev) + { + CWD_RESTORE_FAILED.store(true, Ordering::SeqCst); + if std::thread::panicking() { + eprintln!("failed to restore current_dir to {prev:?}: {error}"); + } else { + panic!("failed to restore current_dir to {prev:?}: {error}"); + } + } + } +} + +#[must_use] +pub(crate) struct EnvScope { + _guard: MutexGuard<'static, ()>, + previous: Vec<(&'static str, Option)>, +} + +impl EnvScope { + pub(crate) fn set(values: &[(&'static str, Option<&OsStr>)]) -> Self { + let guard = ENV_TEST_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let mut previous = Vec::with_capacity(values.len()); + for &(name, value) in values { + previous.push((name, std::env::var_os(name))); + unsafe { + match value { + Some(value) => std::env::set_var(name, value), + None => std::env::remove_var(name), + } + } + } + Self { + _guard: guard, + previous, + } + } +} + +impl Drop for EnvScope { + fn drop(&mut self) { + for (name, value) in self.previous.drain(..).rev() { + unsafe { + match value { + Some(value) => std::env::set_var(name, value), + None => std::env::remove_var(name), + } + } + } + } +} + +pub(crate) static CWD_TEST_LOCK: Mutex<()> = Mutex::new(()); +static CWD_RESTORE_FAILED: AtomicBool = AtomicBool::new(false); +pub(crate) static ENV_TEST_LOCK: Mutex<()> = Mutex::new(()); +pub(crate) static PLUGIN_CONFIG_TEST_LOCK: tokio::sync::Mutex<()> = + tokio::sync::Mutex::const_new(()); + +fn lock_cwd() -> MutexGuard<'static, ()> { + let guard = CWD_TEST_LOCK.lock().expect("CWD_TEST_LOCK poisoned"); + assert!( + !CWD_RESTORE_FAILED.load(Ordering::SeqCst), + "current_dir restore failed in a previous test; aborting to prevent cross-test contamination", + ); + guard +} + +pub(crate) fn accept_bounded(listener: &TcpListener) -> std::net::TcpStream { + listener.set_nonblocking(true).unwrap(); + let deadline = Instant::now() + Duration::from_secs(5); + loop { + match listener.accept() { + Ok((stream, _)) => { + stream.set_nonblocking(false).unwrap(); + stream + .set_read_timeout(Some(Duration::from_secs(5))) + .unwrap(); + return stream; + } + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + assert!( + Instant::now() < deadline, + "timed out waiting for connection" + ); + std::thread::sleep(Duration::from_millis(10)); + } + Err(error) => panic!("accept failed: {error}"), + } + } +} + +pub(crate) fn read_headers(stream: &mut std::net::TcpStream) -> String { + let mut request = Vec::new(); + let mut buffer = [0_u8; 1024]; + while !request.windows(4).any(|window| window == b"\r\n\r\n") { + let count = stream.read(&mut buffer).unwrap(); + assert!(count > 0, "connection closed before complete HTTP headers"); + request.extend_from_slice(&buffer[..count]); + } + String::from_utf8(request).unwrap() +} + +pub(crate) fn header(request: &str, name: &str) -> String { + request + .lines() + .find_map(|line| { + let (candidate, value) = line.split_once(':')?; + candidate + .eq_ignore_ascii_case(name) + .then(|| value.trim().to_string()) + }) + .unwrap_or_else(|| panic!("missing {name} header in {request:?}")) +} diff --git a/crates/cli/tests/fixtures/windows_hook_relay.rs b/crates/cli/tests/fixtures/windows_hook_relay.rs new file mode 100644 index 000000000..e8a03b5d0 --- /dev/null +++ b/crates/cli/tests/fixtures/windows_hook_relay.rs @@ -0,0 +1,41 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use std::ffi::OsString; +use std::io::Read; + +fn main() { + let generation = std::env::var_os("NEMO_RELAY_HOOK_GENERATION") + .expect("NEMO_RELAY_HOOK_GENERATION is required"); + let expected = vec![ + OsString::from("hook-forward"), + OsString::from("codex"), + OsString::from("--gateway-url"), + OsString::from("http://127.0.0.1:47632"), + OsString::from("--generation-file"), + generation, + OsString::from("--generation-token"), + OsString::from("test-generation"), + ]; + let actual = std::env::args_os().skip(1).collect::>(); + if actual != expected { + eprintln!("unexpected hook arguments: {actual:?}"); + std::process::exit(19); + } + + if let Some(path) = std::env::var_os("NEMO_RELAY_HOOK_INPUT_MARKER") { + let mut input = Vec::new(); + std::io::stdin().read_to_end(&mut input).unwrap(); + std::fs::write(path, input).unwrap(); + } + if let Some(path) = std::env::var_os("NEMO_RELAY_HOOK_MARKER") { + std::fs::write(path, "ok\n").unwrap(); + } + if std::env::var_os("NEMO_RELAY_HOOK_EMIT_OUTPUT").is_some() { + println!("hook-stdout"); + eprintln!("hook-stderr"); + } + if let Ok(code) = std::env::var("NEMO_RELAY_HOOK_EXIT_CODE") { + std::process::exit(code.parse().unwrap()); + } +} diff --git a/crates/core/tests/unit/plugin_tests.rs b/crates/core/tests/unit/plugin_tests.rs index e7e7dc969..fe70f1385 100644 --- a/crates/core/tests/unit/plugin_tests.rs +++ b/crates/core/tests/unit/plugin_tests.rs @@ -1155,6 +1155,17 @@ fn test_initialize_plugins_transaction_finishes_after_caller_cancellation() { }) .await .expect("owned initialization transaction did not finish after caller cancellation"); + + tokio::time::timeout(std::time::Duration::from_secs(1), async { + loop { + if *PLUGIN_MUTATION_OWNER.lock().unwrap() == PluginMutationOwner::Idle { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("owned initialization transaction did not release its mutation lease"); }); reset_global(); diff --git a/crates/ffi/tests/integration/plugin_activation_tests.rs b/crates/ffi/tests/integration/plugin_activation_tests.rs index 5ecfb74a6..ab2be8e9f 100644 --- a/crates/ffi/tests/integration/plugin_activation_tests.rs +++ b/crates/ffi/tests/integration/plugin_activation_tests.rs @@ -17,6 +17,11 @@ static DISCOVERED_STATIC_REGISTRATIONS: AtomicUsize = AtomicUsize::new(0); static DISCOVERED_STATIC_CALLBACKS: AtomicUsize = AtomicUsize::new(0); static DISCOVERED_STATIC_CONFIG: Mutex> = Mutex::new(None); +struct NativeFixture { + _source_dir: TempDir, + library: PathBuf, +} + struct PluginDiscoveryTestEnv { previous_cwd: PathBuf, previous_xdg_config_home: Option, @@ -495,51 +500,58 @@ fn plugin_kinds() -> Vec { } fn build_native_fixture() -> &'static Path { - static FIXTURE: OnceLock = OnceLock::new(); - FIXTURE.get_or_init(|| { - let source_dir = TempDir::new().expect("native fixture source tempdir"); - let fixture_dir = source_dir.path().join("native_plugin"); - let source = fixture_dir.join("src"); - std::fs::create_dir_all(&source).expect("native fixture src dir"); - let plugin_path = Path::new(env!("CARGO_MANIFEST_DIR")).join("../plugin"); - let manifest_template = std::fs::read_to_string( - Path::new(env!("CARGO_MANIFEST_DIR")) - .join("../core/tests/fixtures/native_plugin/Cargo.toml"), - ) - .expect("native fixture Cargo.toml"); - let manifest = manifest_template.replace( - r#"nemo-relay-plugin = { path = "../../../../plugin" }"#, - &format!("nemo-relay-plugin = {{ path = {plugin_path:?} }}"), - ); - std::fs::write(fixture_dir.join("Cargo.toml"), manifest) - .expect("write native fixture Cargo.toml"); - std::fs::copy( - Path::new(env!("CARGO_MANIFEST_DIR")) - .join("../core/tests/fixtures/native_plugin/src/lib.rs"), - source.join("lib.rs"), - ) - .expect("copy native fixture source"); - - let target = - Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/ffi-native-plugin-fixture"); - let status = Command::new(std::env::var("CARGO").unwrap_or_else(|_| "cargo".into())) - .arg("build") - .arg("--quiet") - .arg("--manifest-path") - .arg(fixture_dir.join("Cargo.toml")) - .arg("--target-dir") - .arg(&target) - .status() - .expect("native fixture build should start"); - assert!(status.success(), "native fixture build failed: {status}"); - let library = target.join("debug").join(native_library_name()); - assert!( - library.exists(), - "missing native fixture: {}", - library.display() - ); - library - }) + static FIXTURE: OnceLock = OnceLock::new(); + &FIXTURE + .get_or_init(|| { + let source_dir = TempDir::new().expect("native fixture source tempdir"); + let fixture_dir = source_dir.path().join("native_plugin"); + let source = fixture_dir.join("src"); + std::fs::create_dir_all(&source).expect("native fixture src dir"); + let plugin_path = Path::new(env!("CARGO_MANIFEST_DIR")).join("../plugin"); + let manifest_template = std::fs::read_to_string( + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../core/tests/fixtures/native_plugin/Cargo.toml"), + ) + .expect("native fixture Cargo.toml"); + let manifest = manifest_template.replace( + r#"nemo-relay-plugin = { path = "../../../../plugin" }"#, + &format!("nemo-relay-plugin = {{ path = {plugin_path:?} }}"), + ); + std::fs::write(fixture_dir.join("Cargo.toml"), manifest) + .expect("write native fixture Cargo.toml"); + std::fs::copy( + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../core/tests/fixtures/native_plugin/src/lib.rs"), + source.join("lib.rs"), + ) + .expect("copy native fixture source"); + + // Nextest runs each test in a separate process. Keep this process's + // generated crate and target directory together so parallel tests do + // not race on a shared fixture artifact. + let target = source_dir.path().join("target"); + let status = Command::new(std::env::var("CARGO").unwrap_or_else(|_| "cargo".into())) + .arg("build") + .arg("--quiet") + .arg("--manifest-path") + .arg(fixture_dir.join("Cargo.toml")) + .arg("--target-dir") + .arg(&target) + .status() + .expect("native fixture build should start"); + assert!(status.success(), "native fixture build failed: {status}"); + let library = target.join("debug").join(native_library_name()); + assert!( + library.exists(), + "missing native fixture: {}", + library.display() + ); + NativeFixture { + _source_dir: source_dir, + library, + } + }) + .library } fn build_worker_fixture() -> &'static Path { diff --git a/integrations/coding-agents/claude-code/.claude-plugin/plugin.json b/integrations/coding-agents/claude-code/.claude-plugin/plugin.json index 9490f2a02..d28c3c78c 100644 --- a/integrations/coding-agents/claude-code/.claude-plugin/plugin.json +++ b/integrations/coding-agents/claude-code/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "nemo-relay-plugin", "version": "0.6.0", - "description": "Claude Code hooks that forward canonical lifecycle payloads to nemo-relay.", + "description": "Native Relay gateway lifecycle and Claude Code hooks for complete local observability.", "author": { "name": "NVIDIA Corporation and Affiliates", "url": "https://github.com/NVIDIA/NeMo-Relay" @@ -9,6 +9,7 @@ "homepage": "https://github.com/NVIDIA/NeMo-Relay", "repository": "https://github.com/NVIDIA/NeMo-Relay", "license": "Apache-2.0", + "mcpServers": "./.mcp.json", "keywords": [ "nemo-relay", "claude-code", diff --git a/integrations/coding-agents/claude-code/.mcp.json b/integrations/coding-agents/claude-code/.mcp.json new file mode 100644 index 000000000..6abfbb56f --- /dev/null +++ b/integrations/coding-agents/claude-code/.mcp.json @@ -0,0 +1,14 @@ +{ + "mcpServers": { + "nemo-relay": { + "command": "nemo-relay", + "args": [ + "mcp" + ], + "env": { + "NEMO_RELAY_GATEWAY_BIND": "127.0.0.1:47632" + }, + "alwaysLoad": true + } + } +} diff --git a/integrations/coding-agents/claude-code/hooks/hooks.json b/integrations/coding-agents/claude-code/hooks/hooks.json index b43125111..73cb06ef4 100644 --- a/integrations/coding-agents/claude-code/hooks/hooks.json +++ b/integrations/coding-agents/claude-code/hooks/hooks.json @@ -5,7 +5,7 @@ "hooks": [ { "type": "command", - "command": "nemo-relay plugin-shim hook claude", + "command": "nemo-relay hook-forward claude --gateway-url http://127.0.0.1:47632 --forward-only", "timeout": 30 } ] @@ -16,7 +16,7 @@ "hooks": [ { "type": "command", - "command": "nemo-relay plugin-shim hook claude", + "command": "nemo-relay hook-forward claude --gateway-url http://127.0.0.1:47632 --forward-only", "timeout": 30 } ] @@ -27,7 +27,7 @@ "hooks": [ { "type": "command", - "command": "nemo-relay plugin-shim hook claude", + "command": "nemo-relay hook-forward claude --gateway-url http://127.0.0.1:47632 --forward-only", "timeout": 30 } ] @@ -39,7 +39,7 @@ "hooks": [ { "type": "command", - "command": "nemo-relay plugin-shim hook claude", + "command": "nemo-relay hook-forward claude --gateway-url http://127.0.0.1:47632 --forward-only", "timeout": 30 } ] @@ -51,7 +51,7 @@ "hooks": [ { "type": "command", - "command": "nemo-relay plugin-shim hook claude", + "command": "nemo-relay hook-forward claude --gateway-url http://127.0.0.1:47632 --forward-only", "timeout": 30 } ] @@ -63,7 +63,7 @@ "hooks": [ { "type": "command", - "command": "nemo-relay plugin-shim hook claude", + "command": "nemo-relay hook-forward claude --gateway-url http://127.0.0.1:47632 --forward-only", "timeout": 30 } ] @@ -75,7 +75,7 @@ "hooks": [ { "type": "command", - "command": "nemo-relay plugin-shim hook claude", + "command": "nemo-relay hook-forward claude --gateway-url http://127.0.0.1:47632 --forward-only", "timeout": 30 } ] @@ -86,7 +86,7 @@ "hooks": [ { "type": "command", - "command": "nemo-relay plugin-shim hook claude", + "command": "nemo-relay hook-forward claude --gateway-url http://127.0.0.1:47632 --forward-only", "timeout": 30 } ] @@ -97,7 +97,7 @@ "hooks": [ { "type": "command", - "command": "nemo-relay plugin-shim hook claude", + "command": "nemo-relay hook-forward claude --gateway-url http://127.0.0.1:47632 --forward-only", "timeout": 30 } ] @@ -108,7 +108,7 @@ "hooks": [ { "type": "command", - "command": "nemo-relay plugin-shim hook claude", + "command": "nemo-relay hook-forward claude --gateway-url http://127.0.0.1:47632 --forward-only", "timeout": 30 } ] @@ -119,7 +119,7 @@ "hooks": [ { "type": "command", - "command": "nemo-relay plugin-shim hook claude", + "command": "nemo-relay hook-forward claude --gateway-url http://127.0.0.1:47632 --forward-only", "timeout": 30 } ] @@ -130,7 +130,7 @@ "hooks": [ { "type": "command", - "command": "nemo-relay plugin-shim hook claude", + "command": "nemo-relay hook-forward claude --gateway-url http://127.0.0.1:47632 --forward-only", "timeout": 30 } ] @@ -141,7 +141,7 @@ "hooks": [ { "type": "command", - "command": "nemo-relay plugin-shim hook claude", + "command": "nemo-relay hook-forward claude --gateway-url http://127.0.0.1:47632 --forward-only", "timeout": 30 } ] @@ -152,7 +152,7 @@ "hooks": [ { "type": "command", - "command": "nemo-relay plugin-shim hook claude", + "command": "nemo-relay hook-forward claude --gateway-url http://127.0.0.1:47632 --forward-only", "timeout": 30 } ] diff --git a/integrations/coding-agents/codex/.codex-plugin/plugin.json b/integrations/coding-agents/codex/.codex-plugin/plugin.json index ffa5c12ab..865e88009 100644 --- a/integrations/coding-agents/codex/.codex-plugin/plugin.json +++ b/integrations/coding-agents/codex/.codex-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "nemo-relay-plugin", "version": "0.6.0", - "description": "Codex hooks that forward canonical lifecycle payloads to nemo-relay.", + "description": "Native Relay gateway lifecycle and Codex hooks for complete local observability.", "author": { "name": "NVIDIA Corporation and Affiliates", "url": "https://github.com/NVIDIA/NeMo-Relay" @@ -9,6 +9,7 @@ "homepage": "https://github.com/NVIDIA/NeMo-Relay", "repository": "https://github.com/NVIDIA/NeMo-Relay", "license": "Apache-2.0", + "mcpServers": "./.mcp.json", "keywords": [ "nemo-relay", "codex", @@ -17,8 +18,8 @@ ], "interface": { "displayName": "NeMo Relay Plugin", - "shortDescription": "Forward Codex lifecycle hooks to a local NeMo Relay sidecar.", - "longDescription": "Installs command hooks that preserve Codex hook payloads and forward them to nemo-relay for agent, subagent, tool, and lifecycle observability. Full LLM capture also requires sidecar provider routing.", + "shortDescription": "Run the native Relay gateway and capture Codex lifecycle events.", + "longDescription": "Starts the native nemo-relay gateway through a required lifecycle-bound MCP server, routes model traffic through it, and installs command hooks that preserve canonical Codex lifecycle payloads.", "developerName": "NVIDIA", "category": "Coding", "capabilities": [ diff --git a/integrations/coding-agents/codex/.mcp.json b/integrations/coding-agents/codex/.mcp.json new file mode 100644 index 000000000..01d6245cc --- /dev/null +++ b/integrations/coding-agents/codex/.mcp.json @@ -0,0 +1,77 @@ +{ + "nemo-relay": { + "command": "nemo-relay", + "args": [ + "mcp" + ], + "env": { + "NEMO_RELAY_GATEWAY_BIND": "127.0.0.1:47632" + }, + "env_vars": [ + "ALL_PROXY", + "ANTHROPIC_API_KEY", + "APPDATA", + "AWS_ACCESS_KEY_ID", + "AWS_ALLOW_HTTP", + "AWS_CA_BUNDLE", + "AWS_CONFIG_FILE", + "AWS_CONTAINER_AUTHORIZATION_TOKEN", + "AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE", + "AWS_CONTAINER_CREDENTIALS_FULL_URI", + "AWS_CONTAINER_CREDENTIALS_RELATIVE_URI", + "AWS_DEFAULT_REGION", + "AWS_EC2_METADATA_DISABLED", + "AWS_ENDPOINT_URL", + "AWS_PROFILE", + "AWS_REGION", + "AWS_ROLE_ARN", + "AWS_ROLE_SESSION_NAME", + "AWS_SDK_LOAD_CONFIG", + "AWS_SECRET_ACCESS_KEY", + "AWS_SESSION_TOKEN", + "AWS_SHARED_CREDENTIALS_FILE", + "AWS_STS_REGIONAL_ENDPOINTS", + "AWS_WEB_IDENTITY_TOKEN_FILE", + "HOME", + "HTTPS_PROXY", + "HTTP_PROXY", + "LOCALAPPDATA", + "NEMO_RELAY_ANTHROPIC_BASE_URL", + "NEMO_RELAY_GATEWAY_URL", + "NEMO_RELAY_MAX_HOOK_PAYLOAD_BYTES", + "NEMO_RELAY_MAX_PASSTHROUGH_BODY_BYTES", + "NEMO_RELAY_OPENAI_BASE_URL", + "NEMO_RELAY_PLUGIN_IDLE_TIMEOUT_SECS", + "NEMO_RELAY_PYTHON", + "NEMO_RELAY_TRANSPARENT_RUN", + "NO_PROXY", + "OPENAI_API_KEY", + "OTEL_EXPORTER_OTLP_COMPRESSION", + "OTEL_EXPORTER_OTLP_ENDPOINT", + "OTEL_EXPORTER_OTLP_HEADERS", + "OTEL_EXPORTER_OTLP_PROTOCOL", + "OTEL_EXPORTER_OTLP_TIMEOUT", + "OTEL_EXPORTER_OTLP_TRACES_COMPRESSION", + "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", + "OTEL_EXPORTER_OTLP_TRACES_HEADERS", + "OTEL_EXPORTER_OTLP_TRACES_PROTOCOL", + "OTEL_EXPORTER_OTLP_TRACES_TIMEOUT", + "OTEL_RESOURCE_ATTRIBUTES", + "OTEL_SDK_DISABLED", + "OTEL_SERVICE_NAME", + "SSL_CERT_DIR", + "SSL_CERT_FILE", + "TEMP", + "TMPDIR", + "USERPROFILE", + "XDG_CONFIG_HOME", + "XDG_RUNTIME_DIR", + "all_proxy", + "http_proxy", + "https_proxy", + "no_proxy" + ], + "required": true, + "startup_timeout_sec": 20 + } +} diff --git a/integrations/coding-agents/codex/hooks/hooks.json b/integrations/coding-agents/codex/hooks/hooks.json index 43a79c2be..550a462bc 100644 --- a/integrations/coding-agents/codex/hooks/hooks.json +++ b/integrations/coding-agents/codex/hooks/hooks.json @@ -1,12 +1,12 @@ { - "SPDX-License-Identifier": "Apache-2.0", + "description": "SPDX-License-Identifier: Apache-2.0", "hooks": { "SessionStart": [ { "hooks": [ { "type": "command", - "command": "nemo-relay plugin-shim hook codex", + "command": "nemo-relay hook-forward codex --gateway-url http://127.0.0.1:47632 --forward-only", "timeout": 30 } ] @@ -17,7 +17,7 @@ "hooks": [ { "type": "command", - "command": "nemo-relay plugin-shim hook codex", + "command": "nemo-relay hook-forward codex --gateway-url http://127.0.0.1:47632 --forward-only", "timeout": 30 } ] @@ -29,7 +29,7 @@ "hooks": [ { "type": "command", - "command": "nemo-relay plugin-shim hook codex", + "command": "nemo-relay hook-forward codex --gateway-url http://127.0.0.1:47632 --forward-only", "timeout": 30 } ] @@ -41,19 +41,7 @@ "hooks": [ { "type": "command", - "command": "nemo-relay plugin-shim hook codex", - "timeout": 30 - } - ] - } - ], - "PostToolUseFailure": [ - { - "matcher": "*", - "hooks": [ - { - "type": "command", - "command": "nemo-relay plugin-shim hook codex", + "command": "nemo-relay hook-forward codex --gateway-url http://127.0.0.1:47632 --forward-only", "timeout": 30 } ] @@ -65,7 +53,7 @@ "hooks": [ { "type": "command", - "command": "nemo-relay plugin-shim hook codex", + "command": "nemo-relay hook-forward codex --gateway-url http://127.0.0.1:47632 --forward-only", "timeout": 30 } ] @@ -76,7 +64,7 @@ "hooks": [ { "type": "command", - "command": "nemo-relay plugin-shim hook codex", + "command": "nemo-relay hook-forward codex --gateway-url http://127.0.0.1:47632 --forward-only", "timeout": 30 } ] @@ -87,18 +75,7 @@ "hooks": [ { "type": "command", - "command": "nemo-relay plugin-shim hook codex", - "timeout": 30 - } - ] - } - ], - "Notification": [ - { - "hooks": [ - { - "type": "command", - "command": "nemo-relay plugin-shim hook codex", + "command": "nemo-relay hook-forward codex --gateway-url http://127.0.0.1:47632 --forward-only", "timeout": 30 } ] @@ -109,7 +86,7 @@ "hooks": [ { "type": "command", - "command": "nemo-relay plugin-shim hook codex", + "command": "nemo-relay hook-forward codex --gateway-url http://127.0.0.1:47632 --forward-only", "timeout": 30 } ] @@ -120,7 +97,7 @@ "hooks": [ { "type": "command", - "command": "nemo-relay plugin-shim hook codex", + "command": "nemo-relay hook-forward codex --gateway-url http://127.0.0.1:47632 --forward-only", "timeout": 30 } ] @@ -131,18 +108,7 @@ "hooks": [ { "type": "command", - "command": "nemo-relay plugin-shim hook codex", - "timeout": 30 - } - ] - } - ], - "SessionEnd": [ - { - "hooks": [ - { - "type": "command", - "command": "nemo-relay plugin-shim hook codex", + "command": "nemo-relay hook-forward codex --gateway-url http://127.0.0.1:47632 --forward-only", "timeout": 30 } ] diff --git a/justfile b/justfile index 835862adc..d6ba5c5f9 100644 --- a/justfile +++ b/justfile @@ -1057,6 +1057,18 @@ clean: examples/rust-native-plugin/target \ target +# Opt-in: requires a supported Codex installation and is intentionally outside test-rust/CI. +test-codex-plugin-e2e: + ./scripts/test-codex-plugin-e2e.sh + +# Opt-in: requires a supported Claude Code installation and is intentionally outside test-rust/CI. +test-claude-plugin-e2e: + ./scripts/test-claude-plugin-e2e.sh + +# Opt-in: requires a supported Hermes Agent installation and is intentionally outside test-rust/CI. +test-hermes-mcp-e2e: + ./scripts/test-hermes-mcp-e2e.sh + # --set [output_dir=] [ci=true|false] test-rust: #!/usr/bin/env bash @@ -1112,7 +1124,8 @@ test-rust: --output-path "$coverage_out" fi else - cargo test --workspace + cargo test --workspace --exclude nemo-relay-ffi + cargo test -p nemo-relay-ffi -- --test-threads=1 fi # --set [output_dir=] [ci=true|false] @@ -1242,8 +1255,10 @@ test-python-plugin-e2e: 'import socket; s = socket.socket(); s.bind(("127.0.0.1", 0)); print(s.getsockname()[1]); s.close()')" "$cli" --config "$config" --bind "127.0.0.1:$port" >"$tmp/gateway.log" 2>&1 & gateway_pid=$! + gateway_ready_timeout_seconds=30 + gateway_ready_deadline=$((SECONDS + gateway_ready_timeout_seconds)) ready=false - for _ in $(seq 1 100); do + while ((SECONDS < gateway_ready_deadline)); do if "$python_executable" -c \ 'import sys, urllib.request; urllib.request.urlopen(sys.argv[1], timeout=0.2).read()' \ "http://127.0.0.1:$port/healthz" 2>/dev/null; then @@ -1256,6 +1271,11 @@ test-python-plugin-e2e: sleep 0.1 done if [[ "$ready" != true ]]; then + if kill -0 "$gateway_pid" 2>/dev/null; then + echo "gateway remained alive but did not become ready within ${gateway_ready_timeout_seconds}s" >&2 + else + echo "gateway exited before becoming ready" >&2 + fi cat "$tmp/gateway.log" exit 1 fi diff --git a/scripts/README.md b/scripts/README.md index a2acd9b5f..c8fce82ab 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -18,6 +18,14 @@ Keep `scripts/` focused on helpers that are still script-native: - `test-install.ps1`: Run live GitHub release and local interface checks for the PowerShell CLI installer - `test-install-mocks.sh`: Run installer scenarios that require simulated platforms or failures +## Opt-In Coding-Agent E2E Tests + +These checks exercise installed coding-agent clients and are intentionally outside the default Rust and CI test suites. Run the recipe that matches an available local client: + +- `just test-codex-plugin-e2e` +- `just test-claude-plugin-e2e` +- `just test-hermes-mcp-e2e` + ## Internal Layout - `docs/`: Fern reference-generation, migration cleanup, and `docs-website` branch sync helpers. Generated API reference output under `docs/reference/api/*-library-reference/` is ignored and recreated by `just docs`. diff --git a/scripts/test-claude-plugin-e2e.sh b/scripts/test-claude-plugin-e2e.sh new file mode 100755 index 000000000..161759662 --- /dev/null +++ b/scripts/test-claude-plugin-e2e.sh @@ -0,0 +1,351 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +if ! command -v claude >/dev/null 2>&1; then + echo "SKIP: claude is not installed" + exit 0 +fi + +cargo build -p nemo-relay-cli --bin nemo-relay + +work="$(mktemp -d)" +provider_pid="" +background_pids=("") + +cleanup() { + for pid in "${background_pids[@]}"; do + [[ -n "$pid" ]] || continue + kill "$pid" 2>/dev/null || true + wait "$pid" 2>/dev/null || true + done + if [[ -d "$work/install" ]]; then + nemo-relay uninstall claude-code --install-dir "$work/install" >/dev/null 2>&1 || true + fi + if [[ -n "$provider_pid" ]]; then + kill "$provider_pid" 2>/dev/null || true + wait "$provider_pid" 2>/dev/null || true + fi + if [[ "${RELAY_E2E_KEEP_WORK:-0}" == "1" ]]; then + echo "Claude Code E2E workspace retained at $work" >&2 + else + rm -rf "$work" + fi +} +trap cleanup EXIT + +while IFS='=' read -r name _; do + if [[ "$name" == NEMO_RELAY_* ]]; then + unset "$name" + fi +done < <(env) + +export HOME="$work/home" +export XDG_CONFIG_HOME="$work/xdg" +export XDG_DATA_HOME="$work/data" +export XDG_RUNTIME_DIR="$work/runtime" +export TMPDIR="$work/tmp" +export PATH="$repo_root/target/debug:$PATH" +export ANTHROPIC_API_KEY="relay-claude-e2e-key" +export CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 +export DISABLE_AUTOUPDATER=1 +export NEMO_RELAY_GATEWAY_URL="http://127.0.0.1:1" +export NEMO_RELAY_PLUGIN_IDLE_TIMEOUT_SECS=1 + +mkdir -p \ + "$HOME" \ + "$XDG_CONFIG_HOME/nemo-relay" \ + "$XDG_DATA_HOME" \ + "$XDG_RUNTIME_DIR" \ + "$TMPDIR" \ + "$work/atof" \ + "$work/provider-barrier" \ + "$work/workspace" + +provider_ready="$work/provider-ready.json" +provider_log="$work/provider-requests.jsonl" +python3 "$repo_root/scripts/test-support/codex_mock_provider.py" \ + --ready-file "$provider_ready" \ + --log-file "$provider_log" \ + --barrier-dir "$work/provider-barrier" & +provider_pid=$! + +for _ in $(seq 1 100); do + [[ -s "$provider_ready" ]] && break + sleep 0.05 +done +[[ -s "$provider_ready" ]] +provider_address="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["address"])' "$provider_ready")" + +cat >"$XDG_CONFIG_HOME/nemo-relay/config.toml" <"$XDG_CONFIG_HOME/nemo-relay/plugins.toml" <"$output" 2>"$stderr" + python3 - "$output" "$stderr" "$debug" <<'PY' +import json +import sys +from pathlib import Path + +output, stderr, debug = map(Path, sys.argv[1:]) +result = json.loads(output.read_text()) +assert result["subtype"] == "success", (result, stderr.read_text()) +assert result["result"] == "pong", result +log = debug.read_text() +assert log.count("Hook SessionStart:startup") == 1, log +assert log.count("Hook UserPromptSubmit") == 1, log +assert log.count('Hook Stop (Stop) success') == 1, log +assert log.count("SessionEnd:other") == 1, log +assert log.count('MCP server "plugin:nemo-relay-plugin:nemo-relay": Successfully connected') == 1, log +assert '"hasTools":false' in log, log +PY +} + +run_transparent_claude() { + output="$work/claude-transparent.json" + stderr="$work/claude-transparent.stderr" + debug="$work/claude-transparent.debug.log" + ( + cd "$work/workspace" + nemo-relay run \ + --config "$XDG_CONFIG_HOME/nemo-relay/config.toml" \ + -- \ + claude \ + --settings "$work/claude-user-settings.json" \ + -p "ping" \ + --output-format json \ + --no-session-persistence \ + --tools "" \ + --debug-file "$debug" + ) >"$output" 2>"$stderr" + python3 - "$output" "$stderr" "$debug" <<'PY' +import json +import sys +from pathlib import Path + +output, stderr, debug = map(Path, sys.argv[1:]) +result = json.loads(output.read_text()) +assert result["subtype"] == "success", (result, stderr.read_text()) +assert result["result"] == "pong", result +log = debug.read_text() +assert 1 <= log.count("Hook SessionStart:startup") <= 2, log +assert 1 <= log.count("Hook UserPromptSubmit") <= 2, log +assert 1 <= log.count('Hook Stop (Stop) success') <= 2, log +assert 1 <= log.count("SessionEnd:other") <= 2, log +assert log.count('MCP server "plugin:nemo-relay-plugin:nemo-relay": Successfully connected') == 1, log +PY +} + +# The transparent wrapper preserves the explicit Claude settings source. The installed Relay MCP +# borrows the dynamic gateway, while its persistent hooks exit without duplicating ATOF delivery. +cat >"$work/claude-user-settings.json" <<'EOF' +{ + "model": "claude-haiku-4-5", + "enabledPlugins": { + "nemo-relay-plugin@nemo-relay-local": true + } +} +EOF +cp "$HOME/.claude/settings.json" "$work/claude-settings-before-transparent.json" +cp "$work/claude-user-settings.json" "$work/claude-user-settings-before-transparent.json" +: >"$provider_log" +events="$work/atof/events.jsonl" +rm -f "$events" +wait_for_relay_port_release +run_transparent_claude +wait_for_relay_port_release +cmp "$HOME/.claude/settings.json" "$work/claude-settings-before-transparent.json" +cmp "$work/claude-user-settings.json" "$work/claude-user-settings-before-transparent.json" +python3 - "$provider_log" "$events" <<'PY' +import json +import sys +from urllib.parse import urlparse + +requests = [json.loads(line) for line in open(sys.argv[1], encoding="utf-8") if line.strip()] +messages = [row for row in requests if urlparse(row["path"]).path.endswith("/messages")] +assert len(messages) == 1, requests +assert messages[0]["model"] == "claude-haiku-4-5", messages +events = [json.loads(line) for line in open(sys.argv[2], encoding="utf-8") if line.strip()] +turn_starts = [ + event for event in events + if event.get("kind") == "scope" + and event.get("name") == "claude-code-turn" + and event.get("scope_category") == "start" +] +turn_ends = [ + event for event in events + if event.get("kind") == "scope" + and event.get("name") == "claude-code-turn" + and event.get("scope_category") == "end" +] +assert len(turn_starts) == len(turn_ends) == 1, (turn_starts, turn_ends) +PY +nemo-relay doctor --plugin claude-code --install-dir "$work/install" + +wait_for_relay_port_release +: >"$provider_log" +rm -f "$events" +for run_id in $(seq 1 10); do + run_claude "$run_id" + wait_for_relay_port_release +done + +touch "$work/provider-barrier/enabled" +run_claude concurrent-a & +background_pids+=("$!") +run_claude concurrent-b & +background_pids+=("$!") + +python3 - "$work/provider-barrier/arrivals" <<'PY' +import sys +import time +from pathlib import Path + +arrivals = Path(sys.argv[1]) +deadline = time.monotonic() + 20 +while time.monotonic() < deadline: + if arrivals.exists() and int(arrivals.read_text() or "0") >= 2: + raise SystemExit(0) + time.sleep(0.05) +raise SystemExit("concurrent Claude requests did not reach the provider barrier") +PY +touch "$work/provider-barrier/release" + +for pid in "${background_pids[@]}"; do + [[ -n "$pid" ]] || continue + wait "$pid" +done +background_pids=("") +wait_for_relay_port_release + +python3 - "$provider_log" "$work/atof/events.jsonl" "$work" <<'PY' +import json +import sys +from pathlib import Path +from urllib.parse import urlparse + +provider_log, atof_path, work = map(Path, sys.argv[1:]) +requests = [json.loads(line) for line in provider_log.read_text().splitlines()] +messages = [row for row in requests if urlparse(row["path"]).path.endswith("/messages")] +assert len(messages) == 12, messages +assert all(row["x_api_key"] == "relay-claude-e2e-key" for row in messages), messages + +events = [json.loads(line) for line in atof_path.read_text().splitlines()] +turn_starts = [ + event + for event in events + if event.get("kind") == "scope" + and event.get("name") == "claude-code-turn" + and event.get("scope_category") == "start" +] +turn_ends = [ + event + for event in events + if event.get("kind") == "scope" + and event.get("name") == "claude-code-turn" + and event.get("scope_category") == "end" +] +llm_starts = [ + event + for event in events + if event.get("kind") == "scope" + and event.get("name") == "anthropic.messages" + and event.get("scope_category") == "start" +] +llm_ends = [ + event + for event in events + if event.get("kind") == "scope" + and event.get("name") == "anthropic.messages" + and event.get("scope_category") == "end" +] +assert len(turn_starts) == len(turn_ends) == 12, (len(turn_starts), len(turn_ends)) +assert len(llm_starts) == len(llm_ends) == 12, (len(llm_starts), len(llm_ends)) +session_ids = {event["metadata"]["session_id"] for event in turn_starts} +assert len(session_ids) == 12, session_ids + +debug_logs = [ + path for path in work.glob("claude-*.debug.log") + if path.name != "claude-transparent.debug.log" +] +assert len(debug_logs) == 12, debug_logs +PY + +echo "Claude Code plugin E2E passed: 10 cold runs and 2 concurrent runs" diff --git a/scripts/test-codex-plugin-e2e.sh b/scripts/test-codex-plugin-e2e.sh new file mode 100755 index 000000000..9a4e28fb5 --- /dev/null +++ b/scripts/test-codex-plugin-e2e.sh @@ -0,0 +1,806 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +if ! command -v codex >/dev/null 2>&1; then + echo "SKIP: codex is not installed" + exit 0 +fi + +cargo build -p nemo-relay-cli --bin nemo-relay + +work="$(mktemp -d)" +provider_pid="" +background_pids=("") +find_sidecar_file() { + python3 - "${TMPDIR:-$work}" "${XDG_CONFIG_HOME:-$work}" "$1" <<'PY' +import sys +from pathlib import Path + +matches = [ + path + for root in sys.argv[1:3] + for path in Path(root).rglob(sys.argv[3]) + if path.is_file() +] +if matches: + print(max(matches, key=lambda path: path.stat().st_mtime_ns)) +PY +} + +read_sidecar_pid() { + python3 - "$1" <<'PY' +import json +import sys + +with open(sys.argv[1], encoding="utf-8") as source: + print(json.load(source)["pid"]) +PY +} + +cleanup() { + codex_pgids=("") + for pgid_file in "$work"/codex-*.pgid; do + [[ -f "$pgid_file" ]] || continue + pgid="$(cat "$pgid_file" 2>/dev/null || true)" + [[ "$pgid" =~ ^[0-9]+$ ]] || continue + codex_pgids+=("$pgid") + kill -TERM -- "-$pgid" 2>/dev/null || true + done + for pid in "${background_pids[@]}"; do + [[ -n "$pid" ]] || continue + pkill -TERM -P "$pid" 2>/dev/null || true + kill "$pid" 2>/dev/null || true + wait "$pid" 2>/dev/null || true + done + for pgid in "${codex_pgids[@]}"; do + [[ -n "$pgid" ]] || continue + for _ in $(seq 1 50); do + kill -0 -- "-$pgid" 2>/dev/null || break + sleep 0.02 + done + kill -KILL -- "-$pgid" 2>/dev/null || true + done + if [[ -n "$provider_pid" ]]; then + kill "$provider_pid" 2>/dev/null || true + wait "$provider_pid" 2>/dev/null || true + fi + pid_file="$(find_sidecar_file 'sidecar-*.owner.json')" + if [[ -n "$pid_file" && -f "$pid_file" ]]; then + sidecar_pid="$(read_sidecar_pid "$pid_file" 2>/dev/null || true)" + if [[ "$sidecar_pid" =~ ^[0-9]+$ ]]; then + kill "$sidecar_pid" 2>/dev/null || true + for _ in $(seq 1 50); do + kill -0 "$sidecar_pid" 2>/dev/null || break + sleep 0.02 + done + kill -KILL "$sidecar_pid" 2>/dev/null || true + fi + fi + if [[ "${RELAY_E2E_KEEP_WORK:-0}" == "1" ]]; then + echo "Codex E2E workspace retained at $work" >&2 + else + rm -rf "$work" + fi +} +trap cleanup EXIT + +# Remove inherited Relay settings before defining the test-owned environment. +while IFS='=' read -r name _; do + if [[ "$name" == NEMO_RELAY_* ]]; then + unset "$name" + fi +done < <(env) +if env | grep -q '^NEMO_RELAY_'; then + echo "failed to clear ambient NEMO_RELAY_* variables" >&2 + exit 1 +fi + +# Keep a conflicting hook target set so the persistent plugin must preserve its +# explicitly installed hook endpoint instead of inheriting an ambient target. +export NEMO_RELAY_GATEWAY_URL="http://127.0.0.1:1" + +export HOME="$work/home" +export CODEX_HOME="$work/codex-home" +export XDG_CONFIG_HOME="$work/xdg" +export XDG_DATA_HOME="$work/data" +export TMPDIR="$work/tmp" +export PATH="$repo_root/target/debug:$PATH" +export OPENAI_API_KEY="relay-e2e-key" +export NEMO_RELAY_PLUGIN_IDLE_TIMEOUT_SECS=1 +mkdir -p "$HOME" "$CODEX_HOME" "$XDG_CONFIG_HOME/nemo-relay" "$XDG_DATA_HOME" "$TMPDIR" + +provider_ready="$work/provider-ready.json" +provider_log="$work/provider-requests.jsonl" +provider_barrier="$work/provider-barrier" +python3 "$repo_root/scripts/test-support/codex_mock_provider.py" \ + --ready-file "$provider_ready" \ + --log-file "$provider_log" \ + --barrier-dir "$provider_barrier" & +provider_pid=$! + +for _ in $(seq 1 100); do + [[ -s "$provider_ready" ]] && break + sleep 0.05 +done +[[ -s "$provider_ready" ]] +provider_address="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["address"])' "$provider_ready")" + +cat >"$XDG_CONFIG_HOME/nemo-relay/config.toml" <"$XDG_CONFIG_HOME/nemo-relay/plugins.toml" <<'EOF' +version = 1 + +[[components]] +kind = "observability" +enabled = true + +[components.config] +version = 1 + +[components.config.atof] +enabled = true +output_directory = "atof" +filename = "events.jsonl" +mode = "append" +EOF + +wait_for_relay_port_release() { + python3 - <<'PY' +import socket +import time + +deadline = time.monotonic() + 6 +while time.monotonic() < deadline: + with socket.socket() as sock: + sock.settimeout(0.2) + if sock.connect_ex(("127.0.0.1", 47632)) != 0: + raise SystemExit(0) + time.sleep(0.1) +raise SystemExit("Relay port 47632 did not become free") +PY +} + +wait_for_mcp_initialize() { + output_path="$1" + process_pid="$2" + python3 - "$output_path" "$process_pid" <<'PY' +import os +import sys +import time +from pathlib import Path + +output_path = Path(sys.argv[1]) +process_pid = int(sys.argv[2]) +deadline = time.monotonic() + 25 +while time.monotonic() < deadline: + try: + if '"serverInfo"' in output_path.read_text(encoding="utf-8", errors="replace"): + raise SystemExit(0) + except FileNotFoundError: + pass + try: + os.kill(process_pid, 0) + except ProcessLookupError: + raise SystemExit(1) + time.sleep(0.05) +raise SystemExit(1) +PY +} + +wait_for_process_exit() { + process_pid="$1" + for _ in $(seq 1 200); do + kill -0 "$process_pid" 2>/dev/null || return 0 + sleep 0.05 + done + return 1 +} + +run_mcp_once() { + stdout_path="$1" + stderr_path="$2" + request_id="$3" + python3 - "$stdout_path" "$stderr_path" "$request_id" <<'PY' +import os +import signal +import subprocess +import sys + +stdout_path, stderr_path, request_id = sys.argv[1:] +message = ( + '{"jsonrpc":"2.0","id":' + + request_id + + ',"method":"initialize","params":{"protocolVersion":"2025-06-18"}}\n' +).encode() +with open(stdout_path, "wb") as stdout, open(stderr_path, "wb") as stderr: + process = subprocess.Popen( + ["nemo-relay", "mcp"], + stdin=subprocess.PIPE, + stdout=stdout, + stderr=stderr, + start_new_session=True, + ) + try: + process.communicate(message, timeout=15) + except subprocess.TimeoutExpired: + try: + os.killpg(process.pid, signal.SIGTERM) + except ProcessLookupError: + pass + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + try: + os.killpg(process.pid, signal.SIGKILL) + except ProcessLookupError: + pass + process.wait() + raise SystemExit(124) +raise SystemExit(process.returncode) +PY +} + +stop_owned_sidecar() { + owner_path="$1" + python3 - "$owner_path" <<'PY' +import http.client +import json +import sys +from urllib.parse import urlsplit + +with open(sys.argv[1], encoding="utf-8") as source: + owner = json.load(source) +url = urlsplit(owner["url"]) +connection = http.client.HTTPConnection(url.hostname, url.port, timeout=2) +connection.request( + "POST", + "/bootstrap/shutdown", + headers={"X-NeMo-Relay-Bootstrap-Token": owner["shutdown_token"]}, +) +response = connection.getresponse() +response.read() +assert response.status == 204, response.status +PY +} + +wait_for_relay_port_release +install_dir="$work/plugins" +nemo-relay install codex --install-dir "$install_dir" +nemo-relay doctor --plugin codex --install-dir "$install_dir" + +run_codex_ping() { + stdout="$work/codex-$1.stdout" + stderr="$work/codex-$1.stderr" + pgid_path="$work/codex-$1.pgid" + if ! python3 - "$stdout" "$stderr" "$pgid_path" <<'PY' +import os +from pathlib import Path +import signal +import subprocess +import sys + +stdout_path, stderr_path, pgid_path = sys.argv[1:] + + +def stop_process_group(process: subprocess.Popen[bytes]) -> None: + try: + os.killpg(process.pid, signal.SIGTERM) + except ProcessLookupError: + return + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + try: + os.killpg(process.pid, signal.SIGKILL) + except ProcessLookupError: + pass + process.wait() + + +try: + with open(stdout_path, "wb") as stdout, open(stderr_path, "wb") as stderr: + process = subprocess.Popen( + ["codex", "exec", "--skip-git-repo-check", "ping"], + stdout=stdout, + stderr=stderr, + start_new_session=True, + ) + temporary = Path(f"{pgid_path}.tmp") + temporary.write_text(str(process.pid), encoding="utf-8") + temporary.replace(pgid_path) + try: + returncode = process.wait(timeout=60) + except subprocess.TimeoutExpired: + stop_process_group(process) + raise SystemExit(124) +finally: + Path(pgid_path).unlink(missing_ok=True) +raise SystemExit(returncode) +PY + then + echo "Codex run $1 failed" >&2 + cat "$stdout" >&2 + cat "$stderr" >&2 + return 1 + fi + grep -qi "pong" "$stdout" + if ! python3 - "$stderr" <<'PY' +import re +import sys +from collections import Counter + +with open(sys.argv[1], encoding="utf-8", errors="replace") as source: + lines = source.read().splitlines() +failure = re.compile(r"\b(error|failed|failure|panic(?:ked)?|refused|unable|timed?\s*out)\b", re.I) +models_retry = re.compile( + r"connection refused|connect error|failed to (?:connect|send)|error sending request|retry", + re.I, +) +unexpected = [] +for line in lines: + if not failure.search(line): + continue + if "/models" in line.lower() and models_retry.search(line): + continue + lowered = line.lower() + if ( + "failed to warm featured plugin ids cache" in lowered + and "chatgpt.com/backend-api/plugins/featured" in lowered + ): + continue + if ( + "codex_core::shell_snapshot: failed to delete shell snapshot" in lowered + and "kind: notfound" in lowered + and "no such file or directory" in lowered + ): + continue + unexpected.append(line) +if unexpected: + print("\n".join(unexpected), file=sys.stderr) + raise SystemExit(1) + +ansi = re.compile(r"\x1b\[[0-9;]*m") +started = Counter() +completed = Counter() +for raw_line in lines: + line = ansi.sub("", raw_line).strip() + match = re.search(r"(?:^|\s)hook: (SessionStart|UserPromptSubmit|Stop)( Completed)?$", line) + if not match: + continue + target = completed if match.group(2) else started + target[match.group(1)] += 1 +expected = Counter({"SessionStart": 1, "UserPromptSubmit": 1, "Stop": 1}) +if started != expected or completed != expected: + print( + f"unexpected Codex hook counts: started={started}, completed={completed}", + file=sys.stderr, + ) + raise SystemExit(1) +PY + then + echo "Codex run $1 reported an unexpected error" >&2 + cat "$stderr" >&2 + return 1 + fi +} + +run_transparent_codex_ping() { + stdout="$work/codex-transparent.stdout" + stderr="$work/codex-transparent.stderr" + if ! python3 - "$stdout" "$stderr" "$XDG_CONFIG_HOME/nemo-relay/config.toml" "$transparent_project" <<'PY' +import subprocess +import sys + +stdout_path, stderr_path, relay_config, project = sys.argv[1:] +with open(stdout_path, "wb") as stdout, open(stderr_path, "wb") as stderr: + process = subprocess.run( + [ + "nemo-relay", + "run", + "--config", + relay_config, + "--", + "codex", + "--profile", + "relay-user-profile", + "exec", + "--skip-git-repo-check", + "ping", + ], + stdout=stdout, + stderr=stderr, + cwd=project, + timeout=60, + check=False, + ) +raise SystemExit(process.returncode) +PY + then + echo "transparent Codex run with persistent plugin installed failed" >&2 + cat "$stdout" >&2 + cat "$stderr" >&2 + return 1 + fi + grep -qi "pong" "$stdout" + python3 - "$stderr" <<'PY' +import re +import sys +from collections import Counter + +lines = open(sys.argv[1], encoding="utf-8", errors="replace").read().splitlines() +ansi = re.compile(r"\x1b\[[0-9;]*m") +started = Counter() +completed = Counter() +for raw_line in lines: + line = ansi.sub("", raw_line).strip() + match = re.search(r"(?:^|\s)hook: (SessionStart|UserPromptSubmit|Stop)( Completed)?$", line) + if not match: + continue + (completed if match.group(2) else started)[match.group(1)] += 1 +expected = Counter({"SessionStart": 1, "UserPromptSubmit": 1, "Stop": 1}) +# The installed plugin remains enabled and its process-local hook exits without forwarding. Codex +# can therefore report both that hook and the wrapper-owned hook, while the ATOF assertions below +# still require exactly one delivered lifecycle stream. +assert all(count <= 2 for count in started.values()), (started, lines) +assert all(count <= 2 for count in completed.values()), (completed, lines) +if started or completed: + assert set(started) == set(expected) and set(completed) == set(expected), (started, completed, lines) + assert started == completed, (started, completed, lines) +PY +} + +# Transparent mode preserves the selected profile. The installed plugin remains configured, but its +# MCP borrows the wrapper-owned dynamic gateway and its persistent hooks become process-local no-ops. +cat >"$CODEX_HOME/relay-user-profile.config.toml" <<'EOF' +model = "gpt-5.1-codex" +model_reasoning_effort = "low" +EOF +cp "$CODEX_HOME/config.toml" "$work/codex-config-before-transparent.toml" +cp "$CODEX_HOME/relay-user-profile.config.toml" "$work/codex-profile-before-transparent.toml" +: >"$provider_log" +transparent_project="$work/transparent-project" +mkdir -p "$transparent_project" +events="$transparent_project/atof/events.jsonl" +rm -f "$events" +wait_for_relay_port_release +run_transparent_codex_ping +wait_for_relay_port_release +cmp "$CODEX_HOME/config.toml" "$work/codex-config-before-transparent.toml" +cmp "$CODEX_HOME/relay-user-profile.config.toml" "$work/codex-profile-before-transparent.toml" +[[ -z "$(find_sidecar_file 'sidecar-*.owner.json')" ]] +python3 - "$provider_log" "$events" <<'PY' +import json +import sys + +requests = [json.loads(line) for line in open(sys.argv[1], encoding="utf-8") if line.strip()] +responses = [row for row in requests if row["method"] == "POST" and row["path"].endswith("/responses")] +assert len(responses) == 1, requests +assert responses[0]["model"] == "gpt-5.1-codex", responses +events = [json.loads(line) for line in open(sys.argv[2], encoding="utf-8") if line.strip()] +turn_starts = [ + event for event in events + if event.get("kind") == "scope" + and event.get("name") == "codex-turn" + and event.get("scope_category") == "start" +] +turn_ends = [ + event for event in events + if event.get("kind") == "scope" + and event.get("name") == "codex-turn" + and event.get("scope_category") == "end" +] +assert len(turn_starts) == len(turn_ends) == 1, (turn_starts, turn_ends) +assert turn_starts[0].get("data", {}).get("hook_event_name", "").lower() == "userpromptsubmit", turn_starts +assert turn_ends[0].get("metadata", {}).get("hook_event_name", "").lower() == "stop", turn_ends +PY +nemo-relay doctor --plugin codex --install-dir "$install_dir" + +# Exercise incompatible configuration handling before collecting acceptance events. +export NEMO_RELAY_PLUGIN_IDLE_TIMEOUT_SECS=300 +holder_fifo="$work/mcp-holder.stdin" +holder_stdout="$work/mcp-holder.stdout" +holder_stderr="$work/mcp-holder.stderr" +mkfifo "$holder_fifo" +exec 9<>"$holder_fifo" +nemo-relay mcp 9>&- <"$holder_fifo" >"$holder_stdout" 2>"$holder_stderr" & +holder_pid=$! +background_pids+=("$holder_pid") +printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18"}}' >&9 +if ! wait_for_mcp_initialize "$holder_stdout" "$holder_pid"; then + cat "$holder_stderr" >&2 + exit 1 +fi +old_sidecar_pid_file="$(find_sidecar_file 'sidecar-*.owner.json')" +[[ -s "$old_sidecar_pid_file" ]] +old_sidecar_pid="$(read_sidecar_pid "$old_sidecar_pid_file")" +kill -0 "$old_sidecar_pid" +exec 9>&- +if ! wait_for_process_exit "$holder_pid"; then + echo "original MCP client did not exit after its stdin closed" >&2 + exit 1 +fi +wait "$holder_pid" +background_pids=("") +kill -0 "$old_sidecar_pid" + +export OPENAI_API_KEY="relay-e2e-key-rotated" +mismatch_stdout="$work/mcp-mismatch.stdout" +mismatch_stderr="$work/mcp-mismatch.stderr" +if run_mcp_once "$mismatch_stdout" "$mismatch_stderr" 2; then + echo "MCP unexpectedly reused a sidecar with an incompatible credential fingerprint" >&2 + exit 1 +fi +grep -qi "different version or persistent configuration" "$mismatch_stderr" + +nemo-relay install codex --force --install-dir "$install_dir" +for _ in $(seq 1 100); do + kill -0 "$old_sidecar_pid" 2>/dev/null || break + sleep 0.05 +done +if kill -0 "$old_sidecar_pid" 2>/dev/null; then + echo "forced Codex reinstall did not retire the owned sidecar" >&2 + exit 1 +fi +wait_for_relay_port_release + +replacement_stdout="$work/mcp-replacement.stdout" +replacement_stderr="$work/mcp-replacement.stderr" +run_mcp_once "$replacement_stdout" "$replacement_stderr" 3 +grep -q '"serverInfo"' "$replacement_stdout" +replacement_pid_file="$(find_sidecar_file 'sidecar-*.owner.json')" +replacement_owner_file="$(find_sidecar_file 'sidecar-*.owner.json')" +[[ -s "$replacement_pid_file" && -s "$replacement_owner_file" ]] +replacement_pid="$(read_sidecar_pid "$replacement_pid_file")" +[[ "$replacement_pid" != "$old_sidecar_pid" ]] +kill -0 "$replacement_pid" +stop_owned_sidecar "$replacement_owner_file" +wait_for_relay_port_release +rm -f "$replacement_owner_file" "$replacement_pid_file" + +# The acceptance counts below cover only real Codex runs, not bootstrap probes. +: >"$provider_log" +events="$XDG_CONFIG_HOME/nemo-relay/atof/events.jsonl" +rm -f "$events" +export NEMO_RELAY_PLUGIN_IDLE_TIMEOUT_SECS=1 + +for iteration in $(seq 1 10); do + run_codex_ping "cold-$iteration" + wait_for_relay_port_release +done + +export NEMO_RELAY_PLUGIN_IDLE_TIMEOUT_SECS=300 +touch "$provider_barrier/enabled" +run_codex_ping concurrent-1 & +first_pid=$! +background_pids+=("$first_pid") +run_codex_ping concurrent-2 & +second_pid=$! +background_pids+=("$second_pid") + +python3 - "$provider_barrier/arrivals" <<'PY' +import sys +import time +from pathlib import Path + +arrivals_path = Path(sys.argv[1]) +deadline = time.monotonic() + 25 +while time.monotonic() < deadline: + try: + arrivals = int(arrivals_path.read_text(encoding="utf-8")) + except (FileNotFoundError, ValueError): + arrivals = 0 + if arrivals >= 2: + raise SystemExit(0) + time.sleep(0.05) +raise SystemExit("concurrent Codex requests did not reach the provider within 25 seconds") +PY +[[ "$(cat "$provider_barrier/arrivals")" -eq 2 ]] +sidecar_pid_file="$(find_sidecar_file 'sidecar-*.owner.json')" +[[ -s "$sidecar_pid_file" ]] +shared_sidecar_pid="$(read_sidecar_pid "$sidecar_pid_file")" +[[ "$shared_sidecar_pid" =~ ^[0-9]+$ ]] +kill -0 "$shared_sidecar_pid" +touch "$provider_barrier/release" +wait "$first_pid" +wait "$second_pid" +background_pids=("") +[[ "$(read_sidecar_pid "$sidecar_pid_file")" == "$shared_sidecar_pid" ]] +kill -0 "$shared_sidecar_pid" +python3 - <<'PY' +import socket + +with socket.socket() as sock: + sock.settimeout(0.2) + assert sock.connect_ex(("127.0.0.1", 47632)) == 0, "shared Relay gateway stopped early" +PY + +owner_file="$(find_sidecar_file 'sidecar-*.owner.json')" +stop_owned_sidecar "$owner_file" +if ! wait_for_process_exit "$shared_sidecar_pid"; then + echo "shared Relay gateway did not exit after the shutdown handshake" >&2 + exit 1 +fi +wait_for_relay_port_release +rm -f "$owner_file" "$sidecar_pid_file" + +python3 - "$provider_log" "$events" <<'PY' +import collections +import json +import sys + +request_path, event_path = sys.argv[1:] +with open(request_path, encoding="utf-8") as source: + requests = [json.loads(line) for line in source if line.strip()] +response_requests = [ + item for item in requests + if item.get("method") == "POST" and item.get("path", "").endswith("/responses") +] +model_requests = [ + item for item in requests + if item.get("method") == "GET" and item.get("path", "").endswith("/models") +] +assert len(response_requests) == 12, ( + f"expected one provider response per Codex run, got {len(response_requests)}; " + f"all provider requests: {requests}" +) +assert all(item["authorization"] == "Bearer relay-e2e-key-rotated" for item in response_requests), response_requests +assert all(item["relay_client_token"] is None for item in requests), requests +provider_response_ids = {item.get("response_id") for item in response_requests} +assert None not in provider_response_ids, response_requests +assert len(provider_response_ids) == 12, ( + f"expected 12 unique provider response IDs, got {provider_response_ids}" +) +assert len(response_requests) + len(model_requests) == len(requests), requests + +with open(event_path, encoding="utf-8") as source: + events = [json.loads(line) for line in source if line.strip()] +assert events and all(event.get("atof_version") == "0.1" for event in events) + +scope_counts = collections.defaultdict(collections.Counter) +for event in events: + if event.get("kind") == "scope": + scope_counts[event["uuid"]][event["scope_category"]] += 1 +for scope_id, counts in scope_counts.items(): + assert counts == {"start": 1, "end": 1}, f"unbalanced or duplicate scope {scope_id}: {counts}" + +turn_starts = [ + event + for event in events + if event.get("kind") == "scope" + and event.get("scope_category") == "start" + and event.get("category") == "custom" + and event.get("name") == "codex-turn" +] +turn_ends = [ + event + for event in events + if event.get("kind") == "scope" + and event.get("scope_category") == "end" + and event.get("category") == "custom" + and event.get("name") == "codex-turn" +] +session_ids = [event.get("metadata", {}).get("session_id") for event in turn_starts] +session_counts = collections.Counter(session_ids) +summary = sorted( + { + ( + event.get("kind"), + event.get("scope_category"), + event.get("category"), + event.get("name"), + ) + for event in events + } +) +assert len(turn_starts) == 12, ( + f"expected exactly one Codex turn start per run, got {len(turn_starts)}; " + f"sessions: {session_counts}; event shapes: {summary}" +) +assert None not in session_ids +assert len(set(session_ids)) == 12, f"Codex sessions were not isolated: {session_ids}" +assert len(turn_ends) == 12, f"expected exactly one Codex turn end per run, got {len(turn_ends)}" +assert {event["uuid"] for event in turn_starts} == {event["uuid"] for event in turn_ends} +assert all( + event.get("data", {}).get("hook_event_name", "").lower() == "userpromptsubmit" + for event in turn_starts +), turn_starts +assert all( + event.get("metadata", {}).get("hook_event_name", "").lower() == "userpromptsubmit" + for event in turn_starts +), turn_starts +# Stop closes the turn, but the semantic output remains the final provider response. +assert all( + event.get("metadata", {}).get("hook_event_name", "").lower() == "stop" + for event in turn_ends +), turn_ends +assert all("pong" in json.dumps(event.get("data")) for event in turn_ends), turn_ends +turn_start_sessions = { + event["uuid"]: event.get("metadata", {}).get("session_id") for event in turn_starts +} +assert all( + event.get("metadata", {}).get("session_id") == turn_start_sessions[event["uuid"]] + for event in turn_ends +), turn_ends + +llm_starts = [ + event for event in events + if event.get("category") == "llm" and event.get("scope_category") == "start" +] +llm_ends = [ + event for event in events + if event.get("category") == "llm" and event.get("scope_category") == "end" +] +assert len(llm_starts) == 12, f"expected 12 LLM starts, got {len(llm_starts)}" +assert len(llm_ends) == 12, f"expected 12 LLM ends, got {len(llm_ends)}" +assert all("pong" in json.dumps(event) for event in llm_ends), llm_ends +llm_start_by_uuid = {event["uuid"]: event for event in llm_starts} +llm_end_by_uuid = {event["uuid"]: event for event in llm_ends} +assert len(llm_start_by_uuid) == 12, f"duplicate LLM starts: {llm_starts}" +assert llm_start_by_uuid.keys() == llm_end_by_uuid.keys(), ( + f"unmatched LLM scopes: starts={llm_start_by_uuid.keys()}, ends={llm_end_by_uuid.keys()}" +) +llm_starts_by_turn = collections.defaultdict(list) +llm_ends_by_turn = collections.defaultdict(list) +for event in llm_starts: + llm_starts_by_turn[event.get("parent_uuid")].append(event) +for event in llm_ends: + llm_ends_by_turn[event.get("parent_uuid")].append(event) +turn_ids = {event["uuid"] for event in turn_starts} +assert set(llm_starts_by_turn) == turn_ids, ( + f"LLM starts were not attached to every turn: {llm_starts_by_turn}" +) +assert set(llm_ends_by_turn) == turn_ids, ( + f"LLM ends were not attached to every turn: {llm_ends_by_turn}" +) +assert all(len(children) == 1 for children in llm_starts_by_turn.values()), llm_starts_by_turn +assert all(len(children) == 1 for children in llm_ends_by_turn.values()), llm_ends_by_turn + + +def strings(value): + if isinstance(value, dict): + for child in value.values(): + yield from strings(child) + elif isinstance(value, list): + for child in value: + yield from strings(child) + elif isinstance(value, str): + yield value + + +def response_id(event): + matches = provider_response_ids.intersection(strings(event)) + assert len(matches) == 1, ( + f"expected exactly one provider response ID in event, got {matches}: {event}" + ) + return next(iter(matches)) + + +turn_end_by_uuid = {event["uuid"]: event for event in turn_ends} +captured_response_ids = set() +for turn_id in turn_ids: + llm_start = llm_starts_by_turn[turn_id][0] + llm_end = llm_ends_by_turn[turn_id][0] + assert llm_start["uuid"] == llm_end["uuid"] + captured_id = response_id(llm_end) + assert response_id(turn_end_by_uuid[turn_id]) == captured_id + captured_response_ids.add(captured_id) +assert captured_response_ids == provider_response_ids, ( + f"captured/provider response IDs differ: captured={captured_response_ids}, " + f"provider={provider_response_ids}" +) +print( + f"validated 12 captured turns and 12 provider responses; " + f"{len(model_requests)} /models requests reached Relay" +) +PY + +nemo-relay uninstall codex --install-dir "$install_dir" +echo "Codex plugin E2E passed: 10 cold and 2 concurrent runs each invoked and completed SessionStart, UserPromptSubmit, and Stop exactly once; every Stop closed one ATOF turn with the matching provider response; pre-MCP /models retries, featured-plugin cache warnings, and concurrent shell-snapshot cleanup races were ignored" diff --git a/scripts/test-hermes-mcp-e2e.sh b/scripts/test-hermes-mcp-e2e.sh new file mode 100755 index 000000000..696d0a290 --- /dev/null +++ b/scripts/test-hermes-mcp-e2e.sh @@ -0,0 +1,295 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +keep_work="${NEMO_RELAY_E2E_KEEP_WORK:-0}" +cold_runs="${NEMO_RELAY_HERMES_E2E_COLD_RUNS:-10}" + +if ! command -v hermes >/dev/null 2>&1; then + echo "SKIP: hermes is not installed" + exit 0 +fi +cargo build -p nemo-relay-cli --bin nemo-relay + +work="$(mktemp -d)" +provider_pid="" +background_pids=("") + +cleanup() { + for pid in "${background_pids[@]}"; do + [[ -n "$pid" ]] || continue + kill "$pid" 2>/dev/null || true + wait "$pid" 2>/dev/null || true + done + if [[ -n "$provider_pid" ]]; then + kill "$provider_pid" 2>/dev/null || true + wait "$provider_pid" 2>/dev/null || true + fi + for owner in "${XDG_CONFIG_HOME:-}/nemo-relay/bootstrap"/sidecar-*.owner.json; do + [[ -f "$owner" ]] || continue + python3 - "$owner" <<'PY' || true +import json +import sys +import urllib.request +from pathlib import Path + +owner = json.loads(Path(sys.argv[1]).read_text()) +request = urllib.request.Request( + f"{owner['url']}/bootstrap/shutdown", + headers={"x-nemo-relay-bootstrap-token": owner["shutdown_token"]}, + method="POST", +) +try: + with urllib.request.urlopen(request, timeout=2): + pass +except OSError: + pass +PY + done + if [[ "$keep_work" == "1" ]]; then + echo "Hermes MCP E2E work directory preserved at $work" >&2 + return + fi + rm -rf "$work" +} +trap cleanup EXIT + +while IFS='=' read -r name _; do + if [[ "$name" == NEMO_RELAY_* ]]; then + unset "$name" + fi +done < <(env) + +export HOME="$work/home" +export HERMES_HOME="$work/hermes" +export XDG_CONFIG_HOME="$work/xdg" +export XDG_DATA_HOME="$work/data" +export XDG_RUNTIME_DIR="$work/runtime" +export TMPDIR="$work/tmp" +export PATH="$repo_root/target/debug:$PATH" +export OPENAI_API_KEY="relay-hermes-e2e-key" +export OPENAI_BASE_URL="http://127.0.0.1:47632/v1" +export NEMO_RELAY_GATEWAY_URL="http://127.0.0.1:1" +# Hermes drains some shell hooks after the foreground CLI has exited. Keep a short grace period so +# one lifecycle cannot be split across two gateway generations; production retains the gateway for +# 300 seconds. +export NEMO_RELAY_PLUGIN_IDLE_TIMEOUT_SECS=5 +export DISABLE_AUTOUPDATER=1 + +mkdir -p \ + "$HOME" \ + "$HERMES_HOME" \ + "$XDG_CONFIG_HOME/nemo-relay" \ + "$XDG_DATA_HOME" \ + "$XDG_RUNTIME_DIR" \ + "$TMPDIR" \ + "$work/atof" \ + "$work/provider-barrier" \ + "$work/workspace" + +provider_ready="$work/provider-ready.json" +provider_log="$work/provider-requests.jsonl" +python3 "$repo_root/scripts/test-support/codex_mock_provider.py" \ + --ready-file "$provider_ready" \ + --log-file "$provider_log" \ + --barrier-dir "$work/provider-barrier" & +provider_pid=$! + +for _ in $(seq 1 100); do + [[ -s "$provider_ready" ]] && break + sleep 0.05 +done +[[ -s "$provider_ready" ]] +provider_address="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["address"])' "$provider_ready")" + +cat >"$XDG_CONFIG_HOME/nemo-relay/config.toml" <"$XDG_CONFIG_HOME/nemo-relay/plugins.toml" <"$work/doctor.json" + +python3 - "$HERMES_HOME" "$work/doctor.json" "$repo_root/target/debug/nemo-relay" <<'PY' +import json +import sys +from pathlib import Path + +home, doctor_path, relay = map(Path, sys.argv[1:]) +config = (home / "config.yaml").read_text() +assert "mcp_servers:" in config and "nemo-relay:" in config, config +assert str(relay.resolve()) in config, config +assert "- mcp" in config and "- --agent" not in config, config +assert "NEMO_RELAY_GATEWAY_BIND: 127.0.0.1:47632" in config, config +assert "OPENAI_API_KEY: ${OPENAI_API_KEY}" in config, config +generation = home / ".nemo-relay-generation" +assert f"NEMO_RELAY_MCP_GENERATION_FILE: {generation}" in config, config +assert generation == home / ".nemo-relay-generation", generation +assert generation.is_file(), generation +generation_token = generation.read_text().splitlines()[0].strip() +assert f"NEMO_RELAY_MCP_GENERATION: {generation_token}" in config, config + +allowlist = json.loads((home / "shell-hooks-allowlist.json").read_text()) +commands = { + entry["command"] + for entry in allowlist["approvals"] + if "hook-forward hermes" in entry.get("command", "") +} +assert len(commands) == 1, commands +command = commands.pop() +assert f"--generation-token {generation_token}" in command, command +approvals = [entry for entry in allowlist["approvals"] if entry.get("command") == command] +assert len(approvals) == 13, approvals +assert len({entry["event"] for entry in approvals}) == 13, approvals +assert config.count("hook-forward hermes") == 13, config + +doctor = json.loads(doctor_path.read_text()) +hermes = next(agent for agent in doctor["agents"] if agent["name"] == "hermes") +assert hermes["status"] == "pass", hermes +assert "MCP lifecycle" in hermes["annotation"], hermes +PY + +wait_for_relay_port_release() { + python3 - <<'PY' +import socket +import time + +deadline = time.monotonic() + 30 +while time.monotonic() < deadline: + with socket.socket() as sock: + sock.settimeout(0.2) + if sock.connect_ex(("127.0.0.1", 47632)) != 0: + raise SystemExit(0) + time.sleep(0.1) +raise SystemExit("Relay port 47632 did not become free") +PY +} + +run_hermes() { + run_id="$1" + output="$work/hermes-$run_id.stdout" + stderr="$work/hermes-$run_id.stderr" + ( + cd "$work/workspace" + hermes -z "ping" --provider openai-api --model gpt-4o-mini + ) >"$output" 2>"$stderr" + python3 - "$output" "$stderr" <<'PY' +import sys +from pathlib import Path + +output, stderr = map(Path, sys.argv[1:]) +assert output.read_text().strip().lower() == "pong", (output.read_text(), stderr.read_text()) +PY +} + +wait_for_relay_port_release +for run_id in $(seq 1 "$cold_runs"); do + run_hermes "$run_id" + wait_for_relay_port_release +done + +touch "$work/provider-barrier/enabled" +run_hermes concurrent-a & +background_pids+=("$!") +run_hermes concurrent-b & +background_pids+=("$!") + +python3 - "$work/provider-barrier/arrivals" <<'PY' +import socket +import sys +import time +from pathlib import Path + +arrivals = Path(sys.argv[1]) +deadline = time.monotonic() + 30 +while time.monotonic() < deadline: + if arrivals.exists() and int(arrivals.read_text() or "0") >= 2: + with socket.socket() as sock: + sock.settimeout(0.2) + assert sock.connect_ex(("127.0.0.1", 47632)) == 0, "shared Relay gateway is not alive" + raise SystemExit(0) + time.sleep(0.05) +raise SystemExit("concurrent Hermes requests did not reach the provider barrier") +PY +touch "$work/provider-barrier/release" + +for pid in "${background_pids[@]}"; do + [[ -n "$pid" ]] || continue + wait "$pid" +done +background_pids=("") +wait_for_relay_port_release + +python3 - "$provider_log" "$work/atof/events.jsonl" "$cold_runs" <<'PY' +import collections +import json +import sys +from pathlib import Path +from urllib.parse import urlparse + +provider_log, atof_path = map(Path, sys.argv[1:3]) +cold_runs = int(sys.argv[3]) +expected_runs = cold_runs + 2 +requests = [json.loads(line) for line in provider_log.read_text().splitlines() if line.strip()] +completions = [ + row for row in requests if urlparse(row["path"]).path.endswith("/chat/completions") +] +assert len(completions) == expected_runs, completions +assert all(row["authorization"] == "Bearer relay-hermes-e2e-key" for row in completions), completions + +events = [json.loads(line) for line in atof_path.read_text().splitlines() if line.strip()] +assert events and all(event.get("atof_version") == "0.1" for event in events), events +scope_counts = collections.defaultdict(collections.Counter) +for event in events: + if event.get("kind") == "scope": + scope_counts[event["uuid"]][event["scope_category"]] += 1 +for scope_id, counts in scope_counts.items(): + assert counts == {"start": 1, "end": 1}, (scope_id, counts) + +turn_starts = [ + event + for event in events + if event.get("kind") == "scope" + and event.get("scope_category") == "start" + and event.get("name") == "hermes-turn" +] +llm_starts = [ + event + for event in events + if event.get("kind") == "scope" + and event.get("scope_category") == "start" + and event.get("name") == "openai.chat_completions" +] +assert len(turn_starts) == expected_runs, turn_starts +assert len(llm_starts) == expected_runs, llm_starts +session_ids = [event.get("metadata", {}).get("session_id") for event in turn_starts] +assert None not in session_ids and len(set(session_ids)) == expected_runs, session_ids +llm_parents = [event.get("parent_uuid") for event in llm_starts] +assert None not in llm_parents and len(set(llm_parents)) == expected_runs, llm_parents +PY + +echo "Hermes MCP E2E passed: $cold_runs cold runs and 2 concurrent runs" diff --git a/scripts/test-support/codex_mock_provider.py b/scripts/test-support/codex_mock_provider.py new file mode 100644 index 000000000..cf0b25b44 --- /dev/null +++ b/scripts/test-support/codex_mock_provider.py @@ -0,0 +1,380 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Local OpenAI and Anthropic fixture for coding-agent plugin E2E tests.""" + +from __future__ import annotations + +import argparse +import json +import threading +import time +import uuid +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from typing import Any +from urllib.parse import urlparse + + +def response_events(request: dict[str, Any]) -> list[dict[str, Any]]: + response_id = f"resp_{uuid.uuid4().hex}" + item_id = f"msg_{uuid.uuid4().hex}" + model = request.get("model", "gpt-5-codex") + created_at = int(time.time()) + item = { + "id": item_id, + "type": "message", + "status": "completed", + "role": "assistant", + "content": [ + { + "type": "output_text", + "text": "pong", + "annotations": [], + "logprobs": [], + } + ], + } + response = { + "id": response_id, + "object": "response", + "created_at": created_at, + "completed_at": created_at, + "status": "completed", + "background": False, + "error": None, + "incomplete_details": None, + "instructions": None, + "max_output_tokens": None, + "max_tool_calls": None, + "model": model, + "output": [item], + "parallel_tool_calls": True, + "previous_response_id": None, + "prompt_cache_key": None, + "reasoning": {"effort": "medium", "summary": None}, + "safety_identifier": None, + "service_tier": "default", + "store": False, + "temperature": None, + "text": {"format": {"type": "text"}, "verbosity": "medium"}, + "tool_choice": "auto", + "tools": [], + "top_logprobs": 0, + "top_p": None, + "truncation": "disabled", + "usage": { + "input_tokens": 1, + "input_tokens_details": {"cached_tokens": 0}, + "output_tokens": 1, + "output_tokens_details": {"reasoning_tokens": 0}, + "total_tokens": 2, + }, + "user": None, + "metadata": {}, + } + in_progress = {**response, "completed_at": None, "status": "in_progress", "output": []} + return [ + {"type": "response.created", "response": in_progress}, + { + "type": "response.output_item.added", + "response_id": response_id, + "output_index": 0, + "item": {**item, "status": "in_progress", "content": []}, + }, + { + "type": "response.content_part.added", + "response_id": response_id, + "item_id": item_id, + "output_index": 0, + "content_index": 0, + "part": {"type": "output_text", "text": "", "annotations": [], "logprobs": []}, + }, + { + "type": "response.output_text.delta", + "response_id": response_id, + "item_id": item_id, + "output_index": 0, + "content_index": 0, + "delta": "pong", + "logprobs": [], + }, + { + "type": "response.output_text.done", + "response_id": response_id, + "item_id": item_id, + "output_index": 0, + "content_index": 0, + "text": "pong", + "logprobs": [], + }, + { + "type": "response.content_part.done", + "response_id": response_id, + "item_id": item_id, + "output_index": 0, + "content_index": 0, + "part": item["content"][0], + }, + { + "type": "response.output_item.done", + "response_id": response_id, + "output_index": 0, + "item": item, + }, + {"type": "response.completed", "response": response}, + ] + + +def anthropic_events(request: dict[str, Any]) -> list[tuple[str, dict[str, Any]]]: + message_id = f"msg_{uuid.uuid4().hex}" + model = request.get("model", "claude-sonnet-4-5") + return [ + ( + "message_start", + { + "type": "message_start", + "message": { + "id": message_id, + "type": "message", + "role": "assistant", + "content": [], + "model": model, + "stop_reason": None, + "stop_sequence": None, + "usage": {"input_tokens": 1, "output_tokens": 0}, + }, + }, + ), + ( + "content_block_start", + { + "type": "content_block_start", + "index": 0, + "content_block": {"type": "text", "text": ""}, + }, + ), + ( + "content_block_delta", + { + "type": "content_block_delta", + "index": 0, + "delta": {"type": "text_delta", "text": "pong"}, + }, + ), + ("content_block_stop", {"type": "content_block_stop", "index": 0}), + ( + "message_delta", + { + "type": "message_delta", + "delta": {"stop_reason": "end_turn", "stop_sequence": None}, + "usage": {"output_tokens": 1}, + }, + ), + ("message_stop", {"type": "message_stop"}), + ] + + +def chat_completion_chunks(request: dict[str, Any]) -> list[dict[str, Any]]: + completion_id = f"chatcmpl_{uuid.uuid4().hex}" + model = request.get("model", "gpt-4o-mini") + created = int(time.time()) + base = { + "id": completion_id, + "object": "chat.completion.chunk", + "created": created, + "model": model, + } + return [ + { + **base, + "choices": [{"index": 0, "delta": {"role": "assistant", "content": ""}, "finish_reason": None}], + }, + { + **base, + "choices": [{"index": 0, "delta": {"content": "pong"}, "finish_reason": None}], + }, + { + **base, + "choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}], + "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}, + }, + ] + + +class Provider(ThreadingHTTPServer): + def __init__(self, address: tuple[str, int], log_path: Path, barrier_dir: Path) -> None: + super().__init__(address, Handler) + self.log_path = log_path + self.log_lock = threading.Lock() + self.barrier_dir = barrier_dir + self.barrier_lock = threading.Lock() + + def log_request_record(self, record: dict[str, Any]) -> None: + with self.log_lock, self.log_path.open("a", encoding="utf-8") as output: + output.write(json.dumps(record, sort_keys=True) + "\n") + + def wait_at_barrier_if_enabled(self) -> None: + if not (self.barrier_dir / "enabled").exists(): + return + with self.barrier_lock: + arrivals = self.barrier_dir / "arrivals" + count = int(arrivals.read_text(encoding="utf-8") or "0") if arrivals.exists() else 0 + temporary = arrivals.with_suffix(".tmp") + temporary.write_text(str(count + 1), encoding="utf-8") + temporary.replace(arrivals) + deadline = time.monotonic() + 30 + release = self.barrier_dir / "release" + while not release.exists(): + if time.monotonic() >= deadline: + raise TimeoutError("concurrent Codex provider barrier timed out") + time.sleep(0.02) + + +class Handler(BaseHTTPRequestHandler): + server: Provider + + def log_message(self, format: str, *args: Any) -> None: # noqa: A002 + del format, args + + def do_GET(self) -> None: # noqa: N802 + path = urlparse(self.path).path + self.server.log_request_record( + { + "method": "GET", + "path": self.path, + "authorization": self.headers.get("authorization"), + "relay_client_token": self.headers.get("x-nemo-relay-client-token"), + } + ) + if not path.endswith("/models"): + self.send_error(404) + return + body = json.dumps( + { + "object": "list", + "data": [ + {"id": "gpt-5-codex", "object": "model", "owned_by": "openai"}, + {"id": "gpt-4o-mini", "object": "model", "owned_by": "openai"}, + ], + } + ).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def do_POST(self) -> None: # noqa: N802 + length = int(self.headers.get("content-length", "0")) + raw = self.rfile.read(length) + request = json.loads(raw or b"{}") + path = urlparse(self.path).path + response_stream = response_events(request) if path.endswith("/responses") else None + anthropic_stream = anthropic_events(request) if path.endswith("/messages") else None + chat_stream = chat_completion_chunks(request) if path.endswith("/chat/completions") else None + self.server.log_request_record( + { + "method": "POST", + "path": self.path, + "authorization": self.headers.get("authorization"), + "x_api_key": self.headers.get("x-api-key"), + "relay_client_token": self.headers.get("x-nemo-relay-client-token"), + "model": request.get("model"), + "response_id": (response_stream[-1]["response"]["id"] if response_stream else None), + } + ) + if path.endswith("/messages/count_tokens"): + body = json.dumps({"input_tokens": 1}).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + return + if response_stream is None and anthropic_stream is None and chat_stream is None: + self.send_error(404) + return + self.server.wait_at_barrier_if_enabled() + if anthropic_stream is not None and not request.get("stream", False): + body = json.dumps( + { + "id": f"msg_{uuid.uuid4().hex}", + "type": "message", + "role": "assistant", + "content": [{"type": "text", "text": "pong"}], + "model": request.get("model", "claude-sonnet-4-5"), + "stop_reason": "end_turn", + "stop_sequence": None, + "usage": {"input_tokens": 1, "output_tokens": 1}, + } + ).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + return + if chat_stream is not None and not request.get("stream", False): + body = json.dumps( + { + "id": f"chatcmpl_{uuid.uuid4().hex}", + "object": "chat.completion", + "created": int(time.time()), + "model": request.get("model", "gpt-4o-mini"), + "choices": [ + { + "index": 0, + "message": {"role": "assistant", "content": "pong"}, + "finish_reason": "stop", + } + ], + "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}, + } + ).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + return + self.send_response(200) + self.send_header("Content-Type", "text/event-stream") + self.send_header("Cache-Control", "no-cache") + self.send_header("Connection", "close") + self.end_headers() + if response_stream is not None: + for event in response_stream: + self.wfile.write(f"data: {json.dumps(event)}\n\n".encode()) + self.wfile.write(b"data: [DONE]\n\n") + elif chat_stream is not None: + for event in chat_stream: + self.wfile.write(f"data: {json.dumps(event)}\n\n".encode()) + self.wfile.write(b"data: [DONE]\n\n") + else: + for event_name, event in anthropic_stream or []: + self.wfile.write(f"event: {event_name}\ndata: {json.dumps(event)}\n\n".encode()) + self.wfile.flush() + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--ready-file", type=Path, required=True) + parser.add_argument("--log-file", type=Path, required=True) + parser.add_argument("--barrier-dir", type=Path, required=True) + args = parser.parse_args() + args.log_file.parent.mkdir(parents=True, exist_ok=True) + args.log_file.write_text("", encoding="utf-8") + args.barrier_dir.mkdir(parents=True, exist_ok=True) + server = Provider(("127.0.0.1", 0), args.log_file, args.barrier_dir) + temporary = args.ready_file.with_suffix(".tmp") + temporary.write_text( + json.dumps({"address": f"127.0.0.1:{server.server_port}"}), + encoding="utf-8", + ) + temporary.replace(args.ready_file) + server.serve_forever() + + +if __name__ == "__main__": + main()