diff --git a/ATTRIBUTIONS-Rust.md b/ATTRIBUTIONS-Rust.md index 4bdfa809e..f7e2c27a7 100644 --- a/ATTRIBUTIONS-Rust.md +++ b/ATTRIBUTIONS-Rust.md @@ -13124,6 +13124,215 @@ limitations under the License. ``` +## 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 +``` + 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. + +``` + ## futures - 0.3.32 **Repository URL**: https://github.com/rust-lang/futures-rs **License Type(s)**: Apache-2.0 @@ -46058,6 +46267,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 diff --git a/Cargo.lock b/Cargo.lock index 5927aa621..383437c8c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -753,6 +753,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" @@ -1538,10 +1548,12 @@ dependencies = [ "clap_complete", "console 0.16.3", "dialoguer", + "fs2", "futures-util", "http", "http-body-util", "jsonschema", + "libc", "nemo-relay", "nemo-relay-adaptive", "nemo-relay-pii-redaction", @@ -1551,11 +1563,13 @@ dependencies = [ "regex", "reqwest", "ring", + "semver", "serde", "serde_json", "serde_yaml", "sha2", "strum", + "subtle", "tempfile", "thiserror 2.0.18", "tokio", @@ -1564,6 +1578,7 @@ dependencies = [ "toml_edit", "tower", "uuid", + "windows-sys 0.61.2", ] [[package]] @@ -3645,6 +3660,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" diff --git a/README.md b/README.md index ecbca300a..c7d9234c6 100644 --- a/README.md +++ b/README.md @@ -120,10 +120,15 @@ and provider settings for that launched process, then shuts the gateway down when the agent exits. > [!WARNING] -> If generated hooks are inactive, Codex users must review and activate them -> before events appear. The Codex Desktop App has additional limitations. +> `nemo-relay install codex` automatically trusts only the exact hooks owned by +> `nemo-relay-plugin@nemo-relay-local`. It does not trust unrelated user, +> project, or plugin hooks. Manual or source-marketplace installs can still +> require review. Restart an already running Codex app after persistent +> installation. On Windows, a restrictive host Job Object can keep the shared +> Relay gateway scoped to the host process lifetime. +> The Codex Desktop App has additional limitations. > Refer to the [Codex CLI guide](https://docs.nvidia.com/nemo/relay/nemo-relay-cli/codex) for the -> current hook activation caveat and troubleshooting steps. +> current lifecycle, startup, and troubleshooting details. #### 4. Verify the Run @@ -287,7 +292,7 @@ coverage. | Agent | Observability | Security | Optimization | Notes | |:--|:--:|:--:|:--:|:--| | Claude Code | Yes | Yes | Partial | Hook forwarding, pre-tool blocking, and gateway-routed LLM observability are supported. | -| Codex | Yes | Yes | Partial | Hook activation is required; missing session-end behavior limits trajectory finalization and full optimization coverage. | +| Codex | Yes | Yes | Partial | Persistent install verifies the exact plugin hooks. Each `Stop` finalizes a turn snapshot; `SessionEnd` availability remains Codex-version-dependent. | | Hermes Agent | Yes | Yes | Partial | Hook forwarding, pre-tool blocking, and gateway-routed or hook-backed LLM observability are supported. | ### Public API Integrations diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index b38d57413..f1fce2c76 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -36,6 +36,7 @@ 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" dialoguer = { version = "0.11", default-features = false, features = ["password"] } @@ -44,18 +45,26 @@ percent-encoding = "2" reqwest = { version = "0.12", default-features = false, features = ["charset", "http2", "json", "rustls-tls-native-roots", "stream"] } regex = "1" ring = "0.17" +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-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_System_JobObjects", "Win32_System_Threading"] } + [dev-dependencies] opentelemetry = { workspace = true, features = ["trace"] } opentelemetry_sdk = { workspace = true, features = ["trace", "testing"] } diff --git a/crates/cli/src/config.rs b/crates/cli/src/config.rs index d4491d6f6..8507c6b6d 100644 --- a/crates/cli/src/config.rs +++ b/crates/cli/src/config.rs @@ -2,22 +2,42 @@ // SPDX-License-Identifier: Apache-2.0 use std::collections::HashSet; +use std::env; +use std::fs::{self, OpenOptions}; +use std::io::{Read, Seek, SeekFrom, Write}; use std::net::SocketAddr; use std::path::{Path, PathBuf}; +use std::thread; +use std::time::{Duration, Instant}; use axum::http::HeaderMap; use clap::{ArgGroup, Args, Parser, Subcommand, ValueEnum}; -use nemo_relay::plugin::dynamic::DynamicPluginManifest; +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, Serialize}; use serde_json::{Map, Value}; use strum::{Display, IntoStaticStr}; use crate::error::CliError; +use crate::file_io::{LockAttempt, try_lock_exclusive}; use crate::plugin_shim::PluginShimCommand; -use crate::plugins::lifecycle::enforce_required_dynamic_plugin_startup; +#[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; +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"; +/// Maximum regular-file size hashed into persistent gateway identity (512 MiB). +pub(crate) const MAX_BOOTSTRAP_IDENTITY_FILE_BYTES: u64 = 512 * 1024 * 1024; + #[derive(Debug, Clone, Parser)] #[command(name = "nemo-relay")] #[command(about = "Coding-agent gateway for NeMo Relay observability")] @@ -51,7 +71,7 @@ pub(crate) enum Command { 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.", + 0.143.0.", after_help = "Examples:\n \ nemo-relay codex\n \ nemo-relay codex -- exec \"fix the bug in foo.rs\"\n \ @@ -69,6 +89,19 @@ pub(crate) enum Command { 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 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`) @@ -426,6 +459,9 @@ pub(crate) struct ServerArgs { /// Internal override for the plugin configuration file. #[arg(long, env = "NEMO_RELAY_PLUGIN_CONFIG_PATH", hide = true)] pub(crate) plugin_config_path: Option, + /// Internal readiness file used by plugin sidecar bootstrap. + #[arg(long, hide = true)] + pub(crate) ready_file: 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, @@ -445,6 +481,7 @@ impl ServerArgs { || 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() @@ -579,6 +616,7 @@ pub(crate) struct ResolvedConfig { 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)] @@ -690,6 +728,673 @@ pub(crate) fn resolve_server_config(args: &ServerArgs) -> Result 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: ServerArgs, + 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: &ServerArgs, + 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 Ok(None); + }; + 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::sidecar::plugin_idle_timeout() + .map_err(CliError::Config)? + .as_secs(); + let document = serde_json::json!({ + "bootstrap_protocol": 1, + "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); + 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 = 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)) +} + +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 = fs::symlink_metadata(path).map_err(|error| { + format!( + "failed to inspect {description} {} for persistent gateway identity: {error}", + path.display() + ) + })?; + if !metadata.file_type().is_file() { + return Err(format!( + "{description} {} must be a regular file for persistent gateway identity", + path.display() + )); + } + if metadata.len() > MAX_BOOTSTRAP_IDENTITY_FILE_BYTES { + return Err(format!( + "{description} {} exceeds the {MAX_BOOTSTRAP_IDENTITY_FILE_BYTES}-byte persistent gateway identity budget", + 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} {} for persistent gateway identity: {error}", + path.display() + ) + })?; + let opened_metadata = file.metadata().map_err(|error| { + format!( + "failed to inspect {description} {} for persistent gateway identity: {error}", + path.display() + ) + })?; + if !opened_metadata.file_type().is_file() { + return Err(format!( + "{description} {} must be a regular file for persistent gateway identity", + path.display() + )); + } + if opened_metadata.len() > MAX_BOOTSTRAP_IDENTITY_FILE_BYTES { + return Err(format!( + "{description} {} exceeds the {MAX_BOOTSTRAP_IDENTITY_FILE_BYTES}-byte persistent gateway identity budget", + 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} {} for persistent gateway identity: {error}", + path.display() + ) + })?; + if read == 0 { + break; + } + total = total.saturating_add(read as u64); + if total > MAX_BOOTSTRAP_IDENTITY_FILE_BYTES { + return Err(format!( + "{description} {} exceeds the {MAX_BOOTSTRAP_IDENTITY_FILE_BYTES}-byte persistent gateway identity budget", + path.display() + )); + } + consume(&buffer[..read]); + } + Ok(()) +} + +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 PYTHON_ENVIRONMENT_ATTESTATION_DOMAIN: &[u8] = + b"nemo-relay/python-environment-attestation/v1\0"; + +/// 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()?, + ))) + } + + 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()); + let tag = context.sign(); + format!( + "hmac-sha256:{}", + tag.as_ref() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::() + ) + } + + 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() + } + + #[cfg(test)] + pub(crate) fn from_bytes(bytes: &[u8]) -> Self { + Self(hmac::Key::new(hmac::HMAC_SHA256, bytes)) + } +} + +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> { + let path = 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(), + ) + })?; + load_or_create_bootstrap_hmac_key_at(&path) +} + +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(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() + )) + })?; + + 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 mut file = 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>, @@ -790,9 +1495,17 @@ pub(crate) const PLUGINS_TOML: &str = "plugins.toml"; 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(explicit) { + for path in config_paths_scoped(explicit, user_only) { let Some(raw) = read_config_file(&path, explicit.is_some(), "configuration")? else { continue; }; @@ -819,7 +1532,7 @@ fn load_shared_config( } merge_toml(&mut merged, parsed); } - let plugin_toml = load_plugin_toml_config(explicit, plugin_config_path)?; + let plugin_toml = load_plugin_toml_config_scoped(explicit, plugin_config_path, user_only)?; let mut resolved = ResolvedConfig { gateway: GatewayConfig::default(), ..ResolvedConfig::default() @@ -864,11 +1577,16 @@ pub(crate) fn any_config_file_exists() -> bool { // 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 let Ok(cwd) = std::env::current_dir() + if !user_only + && let Ok(cwd) = std::env::current_dir() && let Some(project) = find_project_config(&cwd) { paths.push(project); @@ -885,6 +1603,14 @@ fn config_paths(explicit: Option<&PathBuf>) -> Vec { 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()]; @@ -895,9 +1621,16 @@ fn plugin_config_paths( .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) @@ -932,6 +1665,13 @@ 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(|| { @@ -1029,7 +1769,19 @@ 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)) + 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 @@ -1167,7 +1919,7 @@ fn resolve_dynamic_plugin_refs( 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) + let (manifest, manifest_ref) = load_bounded_dynamic_plugin_manifest(&manifest_path) .map_err(|error| { CliError::Config(format!( "invalid dynamic plugin manifest referenced by {}: {error}", diff --git a/crates/cli/src/file_io.rs b/crates/cli/src/file_io.rs new file mode 100644 index 000000000..34b04a281 --- /dev/null +++ b/crates/cli/src/file_io.rs @@ -0,0 +1,136 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Small, platform-aware filesystem primitives shared by CLI subsystems. + +use std::fs::{self, File}; +use std::io; +use std::path::Path; +#[cfg(windows)] +use std::path::PathBuf; + +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)) +} + +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 + } +} + +/// Atomically replace `path` with `bytes`, creating its parent directory when needed. +pub(crate) 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))] +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> { + 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)] +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")) +} + +#[cfg(test)] +#[path = "../tests/coverage/file_io_tests.rs"] +mod tests; diff --git a/crates/cli/src/install_generation.rs b/crates/cli/src/install_generation.rs new file mode 100644 index 000000000..2b59c530f --- /dev/null +++ b/crates/cli/src/install_generation.rs @@ -0,0 +1,303 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Private install-generation fencing for lifecycle-bound Codex MCP supervisors. + +use std::env; +use std::fs::{self, File, OpenOptions}; +use std::io::{Read, Seek, SeekFrom, Write}; +use std::path::{Path, PathBuf}; +use std::thread; +use std::time::{Duration, Instant}; + +use crate::file_io::{LockAttempt, try_lock_exclusive, try_lock_shared}; + +pub(crate) const GENERATION_FILE_ENV: &str = "NEMO_RELAY_MCP_GENERATION_FILE"; +pub(crate) const GENERATION_FILE_NAME: &str = ".nemo-relay-generation"; +const MAX_GENERATION_TOKEN_BYTES: usize = 128; +const RETIRED_GENERATION_PREFIX: &str = "retired:"; +const DEFAULT_GENERATION_LOCK_TIMEOUT: Duration = Duration::from_secs(5); +const GENERATION_LOCK_RETRY_INTERVAL: Duration = Duration::from_millis(25); + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct InstallGeneration { + path: PathBuf, + token: String, +} + +impl InstallGeneration { + pub(crate) fn capture_from_env() -> Result, String> { + env::var_os(GENERATION_FILE_ENV) + .map(PathBuf::from) + .map(Self::capture) + .transpose() + } + + pub(crate) fn capture(path: PathBuf) -> Result { + let file = open_generation(&path)?; + lock_shared_with_timeout(&file, &path, DEFAULT_GENERATION_LOCK_TIMEOUT)?; + let token = read_generation_file(&file, &path)?; + let current = read_generation_path(&path)?; + if token != current { + return Err(retired_generation_error(&path)); + } + Ok(Self { path, token }) + } + + pub(crate) fn verify_current(&self) -> Result<(), String> { + let file = open_generation(&self.path).map_err(|_| retired_generation_error(&self.path))?; + lock_shared_with_timeout(&file, &self.path, DEFAULT_GENERATION_LOCK_TIMEOUT) + .map_err(|_| retired_generation_error(&self.path))?; + let locked = read_generation_file(&file, &self.path) + .map_err(|_| retired_generation_error(&self.path))?; + let current = + read_generation_path(&self.path).map_err(|_| retired_generation_error(&self.path))?; + if locked != self.token || current != self.token { + return Err(retired_generation_error(&self.path)); + } + Ok(()) + } +} + +pub(crate) struct GenerationRetirement { + lock: Option, + path: PathBuf, + original: GenerationMarker, + changed: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +enum GenerationMarker { + Active(String), + Retired(String), +} + +impl GenerationMarker { + fn token(&self) -> &str { + match self { + Self::Active(token) | Self::Retired(token) => token, + } + } + + fn encoded(&self) -> String { + match self { + Self::Active(token) => format!("{token}\n"), + Self::Retired(token) => format!("{RETIRED_GENERATION_PREFIX}{token}\n"), + } + } + + fn is_retired(&self) -> bool { + matches!(self, Self::Retired(_)) + } +} + +impl GenerationRetirement { + pub(crate) fn acquire(path: &Path) -> Result, String> { + Self::acquire_with_timeout(path, DEFAULT_GENERATION_LOCK_TIMEOUT) + } + + pub(crate) fn acquire_with_timeout( + path: &Path, + timeout: Duration, + ) -> Result, String> { + let file = match OpenOptions::new().read(true).write(true).open(path) { + Ok(file) => file, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => { + return Err(format!( + "failed to open MCP install generation {}: {error}", + path.display() + )); + } + }; + lock_exclusive_with_timeout(&file, path, timeout)?; + let original = read_generation_marker(&file, path)?; + Ok(Some(Self { + lock: Some(file), + path: path.to_owned(), + original, + changed: false, + })) + } + + /// Persistently invalidate this generation and release its exclusive lock before moving the + /// plugin tree. A retired marker cannot be adopted by an old or newly launched MCP process, + /// but remains recognizable so an interrupted uninstall can resume. + pub(crate) fn invalidate_for_replacement(&mut self) -> Result<(), String> { + if self.original.is_retired() || self.changed { + self.lock = None; + return Ok(()); + } + let retired = GenerationMarker::Retired(self.original.token().to_owned()); + let file = self.lock.as_mut().ok_or_else(|| { + format!( + "MCP install generation {} is not locked", + self.path.display() + ) + })?; + self.changed = true; + replace_generation_marker(file, &self.path, &retired, "invalidate")?; + self.lock = None; + Ok(()) + } + + /// 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; + return Ok(()); + } + let mut file = match self.lock.take() { + Some(file) => file, + None => OpenOptions::new() + .write(true) + .open(&self.path) + .map_err(|error| { + format!( + "failed to reopen MCP install generation {} for rollback: {error}", + self.path.display() + ) + })?, + }; + replace_generation_marker(&mut file, &self.path, &self.original, "restore")?; + self.changed = false; + Ok(()) + } +} + +fn replace_generation_marker( + file: &mut File, + path: &Path, + marker: &GenerationMarker, + operation: &str, +) -> Result<(), String> { + write_generation_marker(file, marker).map_err(|error| { + format!( + "failed to {operation} MCP install generation {}: {error}", + path.display() + ) + }) +} + +fn write_generation_marker(file: &mut File, marker: &GenerationMarker) -> std::io::Result<()> { + file.set_len(0)?; + file.seek(SeekFrom::Start(0))?; + file.write_all(marker.encoded().as_bytes())?; + file.sync_all() +} + +fn lock_shared_with_timeout(file: &File, path: &Path, timeout: Duration) -> Result<(), String> { + lock_with_timeout(file, path, timeout, false) +} + +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() + )); + } + } + } +} + +pub(crate) fn write_new_generation(path: &Path) -> Result<(), String> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .map_err(|error| format!("failed to create {}: {error}", parent.display()))?; + } + fs::write(path, format!("{}\n", uuid::Uuid::now_v7())) + .map_err(|error| format!("failed to write {}: {error}", 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_path(path: &Path) -> Result { + let file = open_generation(path)?; + read_generation_file(&file, path) +} + +fn read_generation_file(file: &File, path: &Path) -> Result { + match read_generation_marker(file, path)? { + GenerationMarker::Active(token) => Ok(token), + GenerationMarker::Retired(_) => Err(retired_generation_error(path)), + } +} + +fn read_generation_marker(file: &File, path: &Path) -> Result { + let mut raw = String::new(); + file.take(MAX_GENERATION_TOKEN_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_TOKEN_BYTES { + return Err(format!( + "MCP install generation {} exceeds the {MAX_GENERATION_TOKEN_BYTES}-byte limit", + path.display() + )); + } + let token = raw.trim(); + if token.is_empty() { + return Err(format!( + "MCP install generation {} is empty", + path.display() + )); + } + match token.strip_prefix(RETIRED_GENERATION_PREFIX) { + Some("") => Err(format!( + "MCP install generation {} has a retired marker without a token", + path.display() + )), + Some(token) => Ok(GenerationMarker::Retired(token.to_owned())), + None => Ok(GenerationMarker::Active(token.to_owned())), + } +} + +fn retired_generation_error(path: &Path) -> String { + format!( + "Codex plugin MCP install generation at {} has been retired", + path.display() + ) +} + +#[cfg(test)] +#[path = "../tests/coverage/install_generation_tests.rs"] +mod tests; diff --git a/crates/cli/src/installer.rs b/crates/cli/src/installer.rs index 364f229e9..9104655df 100644 --- a/crates/cli/src/installer.rs +++ b/crates/cli/src/installer.rs @@ -10,12 +10,9 @@ use serde_json::{Value, json}; use crate::config::{CodingAgent, GatewayMode, HookForwardCommand}; use crate::error::CliError; -// Claude Code's hook loader strictly whitelists event names — any unknown event causes the -// entire hooks file to be rejected (no hooks register). Only events present in Claude Code's -// whitelist as of 2.1.x belong here. 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] = &[ +// Each host receives only events in its advertised hook schema. Unknown Codex events are ignored +// by the loader, which would make generated hooks impossible to discover and trust exhaustively. +const CLAUDE_HOOK_EVENTS: &[&str] = &[ "SessionStart", "UserPromptSubmit", "PreToolUse", @@ -31,6 +28,19 @@ const HOOK_EVENTS: &[&str] = &[ "SessionEnd", ]; +const CODEX_HOOK_EVENTS: &[&str] = &[ + "SessionStart", + "UserPromptSubmit", + "PreToolUse", + "PostToolUse", + "PermissionRequest", + "SubagentStart", + "SubagentStop", + "Stop", + "PreCompact", + "PostCompact", +]; + const HOOK_FORWARD_TIMEOUT: Duration = Duration::from_secs(2); const HERMES_HOOK_EVENTS: &[&str] = &[ @@ -215,11 +225,11 @@ pub(crate) fn hook_forward_command(executable: &str, agent: CodingAgent) -> Stri } fn claude_hooks(command: &str) -> Value { - hooks_for_events(HOOK_EVENTS, command, true) + hooks_for_events(CLAUDE_HOOK_EVENTS, command, true) } fn codex_hooks(command: &str) -> Value { - hooks_for_events(HOOK_EVENTS, command, true) + hooks_for_events(CODEX_HOOK_EVENTS, command, true) } // Generates Hermes YAML-compatible hook groups. Hermes expects direct command entries rather than diff --git a/crates/cli/src/launcher.rs b/crates/cli/src/launcher.rs index 4a9fa13db..58a51fe01 100644 --- a/crates/cli/src/launcher.rs +++ b/crates/cli/src/launcher.rs @@ -406,7 +406,7 @@ impl PreparedRun { // 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. + // current supported Codex releases. Requires codex-cli >= 0.143.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 diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index d5947b0b0..15439b4fa 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -10,10 +10,14 @@ mod completions_install; mod config; mod doctor; mod error; +mod file_io; mod gateway; +mod install_generation; mod installer; mod json_path; mod launcher; +mod mcp; +mod mcp_environment; mod model; mod model_pricing; mod plugin_install; @@ -22,6 +26,7 @@ mod plugins; mod server; mod session; mod setup; +mod sidecar; use std::process::ExitCode; @@ -37,6 +42,10 @@ use crate::config::{ // exit. Errors are printed once here so subcommands can return structured errors without also // owning process-level reporting. async fn main() -> ExitCode { + if let Err(error) = sidecar::join_sidecar_job_from_env() { + eprintln!("{error}"); + return ExitCode::FAILURE; + } match run().await { Ok(code) => code, Err(error) => { @@ -80,6 +89,7 @@ async fn run_command(command: Command, server: &ServerArgs) -> Result { launcher::easy_path(CodingAgent::Hermes, command, Some(server)).await } + Command::Mcp => mcp::run(server).await, Command::Config(command) => run_config(command).await, Command::Plugins(command) => run_plugins(command, server), Command::ModelPricing(command) => run_pricing(command), @@ -198,7 +208,15 @@ async fn run_default(server_args: &ServerArgs) -> Result Result { + // Configuration is resolved before reading stdin, but the gateway process is not acquired + // until the session receives a valid MCP initialize request. + let gateway = gateway::GatewayPlan::resolve(server_args).await?; + let frames = transport::spawn_stdin_reader()?; + session::run(gateway, frames, tokio::io::stdout()).await?; + Ok(ExitCode::SUCCESS) +} + +fn default_mcp_bind() -> SocketAddr { + crate::sidecar::DEFAULT_BIND + .parse() + .expect("default MCP gateway bind is valid") +} + +#[cfg(test)] +fn request_requires_gateway(line: &str) -> bool { + protocol::evaluate_frame(line).requires_gateway +} + +#[cfg(test)] +async fn run_session( + bind: SocketAddr, + gateway_url: String, + sidecar_args: Vec, + bootstrap_fingerprint: String, + heartbeat_interval: std::time::Duration, + reader: R, + writer: W, +) -> Result<(), CliError> +where + R: tokio::io::AsyncBufRead + Unpin, + W: tokio::io::AsyncWrite + Unpin, +{ + let lease = gateway::GatewayPlan::test_lease( + bind, + gateway_url, + sidecar_args, + bootstrap_fingerprint, + heartbeat_interval, + ); + session::serve_with_lease(lease, reader, writer).await +} + +#[cfg(test)] +use gateway::{ + maintain_gateway_with, maintain_gateway_with_generation, verify_bootstrap_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/mcp_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..f5bddcecd --- /dev/null +++ b/crates/cli/src/mcp/gateway.rs @@ -0,0 +1,252 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Acquisition and liveness lease for the shared Codex gateway. + +#[cfg(test)] +use std::ffi::OsString; +use std::net::SocketAddr; +use std::time::Duration; + +#[cfg(test)] +use crate::config::CodingAgent; +use crate::config::ServerArgs; +use crate::error::CliError; +use crate::install_generation::InstallGeneration; +use crate::sidecar::{GatewayBootstrap, GatewaySpec}; + +const UNHEALTHY_CHECKS_BEFORE_RESTART: u8 = 3; + +#[derive(Clone)] +pub(super) struct GatewayPlan { + spec: GatewaySpec, + heartbeat_interval: Duration, + generation: Option, +} + +impl GatewayPlan { + pub(super) async fn resolve(server_args: &ServerArgs) -> Result { + let generation = tokio::task::spawn_blocking(InstallGeneration::capture_from_env) + .await + .map_err(|error| { + CliError::Launch(format!("MCP generation capture task failed: {error}")) + })? + .map_err(CliError::Launch)?; + let bind = server_args.bind.unwrap_or_else(super::default_mcp_bind); + let launch = crate::sidecar::resolve_codex_gateway(server_args, bind)?; + let heartbeat_interval = + crate::sidecar::plugin_heartbeat_interval().map_err(CliError::Launch)?; + Ok(Self { + spec: launch.gateway, + heartbeat_interval, + generation, + }) + } + + pub(super) async fn acquire(&self) -> Result { + let bootstrap = ensure_gateway(self.spec.clone(), self.generation.clone()).await?; + let plan = self.clone(); + let monitor = tokio::spawn(async move { plan.monitor(bootstrap.endpoint.url).await }); + Ok(GatewayLease { monitor }) + } + + async fn monitor(self, gateway_url: String) -> 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_with_generation( + self.spec.bind(), + gateway_url, + self.heartbeat_interval, + move |url| { + let spec = health_spec.clone(); + async move { + tokio::task::spawn_blocking(move || spec.is_healthy(&url)) + .await + .map_err(|error| { + CliError::Launch(format!("gateway heartbeat task failed: {error}")) + }) + } + }, + move |_bind| ensure_gateway(restart_spec.clone(), restart_generation.clone()), + move || { + let generation = verify_generation.clone(); + async move { verify_generation_async(generation).await } + }, + ) + .await + } + + #[cfg(test)] + pub(super) fn test_lease( + bind: SocketAddr, + gateway_url: String, + sidecar_args: Vec, + bootstrap_fingerprint: String, + heartbeat_interval: Duration, + ) -> GatewayLease { + let plan = Self { + spec: GatewaySpec::new(CodingAgent::Codex, bind) + .with_launch_args(sidecar_args) + .with_fingerprint(bootstrap_fingerprint), + heartbeat_interval, + generation: None, + }; + let monitor = tokio::spawn(async move { plan.monitor(gateway_url).await }); + GatewayLease { monitor } + } +} + +/// An active liveness lease. Dropping it stops heartbeats immediately. +pub(super) struct GatewayLease { + monitor: tokio::task::JoinHandle>, +} + +impl GatewayLease { + pub(super) async fn wait(&mut self) -> Result<(), CliError> { + (&mut self.monitor).await.map_err(|error| { + CliError::Launch(format!("gateway maintenance task failed: {error}")) + })? + } +} + +impl Drop for GatewayLease { + fn drop(&mut self) { + self.monitor.abort(); + } +} + +async fn ensure_gateway( + spec: GatewaySpec, + generation: Option, +) -> Result { + tokio::task::spawn_blocking(move || { + if let Some(generation) = generation.as_ref() { + generation.verify_current()?; + } + let bootstrap = spec.ensure()?; + if let Some(generation) = generation.as_ref() { + verify_bootstrap_generation(generation)?; + } + Ok(bootstrap) + }) + .await + .map_err(|error| CliError::Launch(format!("gateway bootstrap task failed: {error}")))? + .map_err(CliError::Launch) +} + +pub(super) fn verify_bootstrap_generation(generation: &InstallGeneration) -> Result<(), String> { + // A replacement MCP may already be reusing a compatible gateway started between the two + // generation checks. Leave a ready gateway for reuse or normal idle cleanup. Failures before + // readiness remain armed and are terminated by the sidecar launcher. + generation.verify_current() +} + +async fn verify_generation_async(generation: Option) -> Result<(), CliError> { + tokio::task::spawn_blocking(move || { + generation + .as_ref() + .map_or(Ok(()), InstallGeneration::verify_current) + }) + .await + .map_err(|error| CliError::Launch(format!("MCP generation verification task failed: {error}")))? + .map_err(CliError::Launch) +} + +#[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) -> RFuture, + RFuture: std::future::Future>, +{ + maintain_gateway_with_generation( + bind, + gateway_url, + heartbeat_interval, + healthy, + restart, + || async { Ok(()) }, + ) + .await +} + +pub(super) async fn maintain_gateway_with_generation( + bind: SocketAddr, + mut gateway_url: String, + heartbeat_interval: Duration, + mut healthy: H, + mut restart: R, + mut verify_generation: G, +) -> Result<(), CliError> +where + H: FnMut(String) -> HFuture, + HFuture: std::future::Future>, + R: FnMut(SocketAddr) -> RFuture, + RFuture: std::future::Future>, + G: FnMut() -> GFuture, + GFuture: std::future::Future>, +{ + let mut heartbeat = tokio::time::interval(heartbeat_interval); + let mut recovery = RecoveryState::default(); + heartbeat.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + heartbeat.tick().await; + loop { + heartbeat.tick().await; + verify_generation().await?; + if healthy(gateway_url.clone()).await? { + recovery.record_healthy(); + continue; + } + if !recovery.record_failure()? { + continue; + } + verify_generation().await?; + let bootstrap = restart(bind).await?; + gateway_url = bootstrap.endpoint.url; + recovery.record_recovery(bootstrap.started); + } +} + +#[derive(Default)] +struct RecoveryState { + consecutive_failures: u8, + restarted: bool, +} + +impl RecoveryState { + fn record_healthy(&mut self) { + self.consecutive_failures = 0; + } + + /// Returns true when the caller should coordinate recovery. + fn record_failure(&mut self) -> Result { + self.consecutive_failures = self.consecutive_failures.saturating_add(1); + if self.consecutive_failures < UNHEALTHY_CHECKS_BEFORE_RESTART { + return Ok(false); + } + if self.restarted { + return Err(CliError::Launch( + "shared Relay gateway became unhealthy after its coordinated restart".into(), + )); + } + Ok(true) + } + + fn record_recovery(&mut self, started: bool) { + self.consecutive_failures = 0; + self.restarted |= started; + } +} + +#[cfg(test)] +#[path = "../../tests/coverage/mcp_gateway_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..80d7c3df1 --- /dev/null +++ b/crates/cli/src/mcp/protocol.rs @@ -0,0 +1,106 @@ +// 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-06-18"; + +/// Result of decoding one newline-delimited MCP frame. +pub(super) struct FrameAction { + pub(super) response: Option, + pub(super) requires_gateway: bool, +} + +/// Parse a frame once and derive both its protocol response and bootstrap requirement. +pub(super) fn evaluate_frame(frame: &str) -> FrameAction { + match serde_json::from_str::(frame) { + Ok(message) => FrameAction { + requires_gateway: is_valid_initialize(&message), + response: response_for(&message), + }, + Err(_) => FrameAction { + response: Some(jsonrpc_error(Value::Null, -32700, "Parse error")), + requires_gateway: false, + }, + } +} + +fn is_valid_initialize(message: &Value) -> bool { + valid_jsonrpc_request(message) + && message.get("method").and_then(Value::as_str) == Some("initialize") + && message + .pointer("/params/protocolVersion") + .and_then(Value::as_str) + .is_some() +} + +fn valid_jsonrpc_request(message: &Value) -> bool { + message.is_object() + && message.get("jsonrpc").and_then(Value::as_str) == Some("2.0") + && message.get("id").is_some() +} + +pub(super) fn response_for(message: &Value) -> Option { + let id = message.get("id").cloned(); + if !message.is_object() || message.get("jsonrpc").and_then(Value::as_str) != Some("2.0") { + return Some(jsonrpc_error( + id.unwrap_or(Value::Null), + -32600, + "Invalid Request", + )); + } + let id = id?; + let method = message.get("method").and_then(Value::as_str); + 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 requested_protocol == MCP_PROTOCOL_VERSION { + requested_protocol + } else { + MCP_PROTOCOL_VERSION + }; + Some(json!({ + "jsonrpc": "2.0", + "id": id, + "result": { + "protocolVersion": protocol_version, + "capabilities": { "tools": {} }, + "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")), + } +} + +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..b84871660 --- /dev/null +++ b/crates/cli/src/mcp/session.rs @@ -0,0 +1,89 @@ +// 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, GatewayPlan}; +use super::protocol::{FrameAction, evaluate_frame}; +use super::transport::FrameReceiver; +use crate::error::CliError; + +pub(super) async fn run( + gateway: GatewayPlan, + mut frames: FrameReceiver, + mut writer: W, +) -> Result<(), CliError> +where + W: AsyncWrite + Unpin, +{ + let mut lease: Option = None; + loop { + let received = match lease.as_mut() { + Some(active) => tokio::select! { + frame = frames.recv() => frame, + result = active.wait() => return result, + }, + None => frames.recv().await, + }; + let Some(frame) = received else { + return Ok(()); + }; + let frame = frame?; + let action = evaluate_frame(&frame); + if action.requires_gateway && lease.is_none() { + lease = Some(gateway.acquire().await?); + } + 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..781da7d25 --- /dev/null +++ b/crates/cli/src/mcp_environment.rs @@ -0,0 +1,179 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Environment names shared by Codex MCP generation and gateway compatibility checks. + +use std::collections::BTreeSet; + +use serde_json::Value; + +const BASE_MCP_ENV_VARS: &[&str] = &[ + "ANTHROPIC_API_KEY", + "APPDATA", + "AWS_ACCESS_KEY_ID", + "AWS_ALLOW_HTTP", + "AWS_CONFIG_FILE", + "AWS_DEFAULT_REGION", + "AWS_ENDPOINT_URL", + "AWS_PROFILE", + "AWS_REGION", + "AWS_SECRET_ACCESS_KEY", + "AWS_SESSION_TOKEN", + "AWS_SHARED_CREDENTIALS_FILE", + "HOME", + "HTTPS_PROXY", + "HTTP_PROXY", + "LOCALAPPDATA", + "NEMO_RELAY_ANTHROPIC_BASE_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", + "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", + "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_GATEWAY_URL", + "NEMO_RELAY_HOST_SOCKET", + "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 = BASE_MCP_ENV_VARS + .iter() + .map(|name| (*name).to_string()) + .collect::>(); + 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() +} + +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/model.rs b/crates/cli/src/model.rs index 4d7e8a792..32b1bcfe4 100644 --- a/crates/cli/src/model.rs +++ b/crates/cli/src/model.rs @@ -30,7 +30,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/plugin_install/host.rs b/crates/cli/src/plugin_install/host.rs index d8c9f0928..709194cb9 100644 --- a/crates/cli/src/plugin_install/host.rs +++ b/crates/cli/src/plugin_install/host.rs @@ -7,6 +7,7 @@ use std::env; use std::path::{Path, PathBuf}; use std::process::Command; +use semver::Version; use serde_json::Value; #[cfg(test)] @@ -14,12 +15,12 @@ use serde_json::json; use crate::config::PluginHost; -use super::state::{PluginInstallOptions, PluginLayout}; +use super::state::PluginInstallOptions; use super::{MARKETPLACE_NAME, PLUGIN_NAME, RELAY_COMMAND, host_cli}; pub(super) fn run_host_marketplace_registration( host: PluginHost, - layout: &PluginLayout, + marketplace_root: &Path, options: &PluginInstallOptions, runner: &dyn CommandRunner, ) -> Result<(), String> { @@ -29,7 +30,7 @@ pub(super) fn run_host_marketplace_registration( "plugin".into(), "marketplace".into(), "add".into(), - layout.marketplace_root.display().to_string(), + marketplace_root.display().to_string(), ], options, runner, @@ -291,8 +292,9 @@ pub(super) fn require_relay( return Ok(PathBuf::from(RELAY_COMMAND)); } runner - .resolve_executable(RELAY_COMMAND)? - .ok_or_else(|| "required `nemo-relay` executable was not found on PATH".into()) + .current_executable() + .map(|path| path.canonicalize().unwrap_or(path)) + .map(crate::plugin_shim::portable_executable_path) } pub(super) fn validate_relay_plugin_shim( @@ -315,6 +317,26 @@ pub(super) fn validate_relay_plugin_shim( } } +pub(super) fn validate_relay_mcp( + relay: &Path, + options: &PluginInstallOptions, + runner: &dyn CommandRunner, +) -> Result<(), String> { + if options.dry_run { + return Ok(()); + } + 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}; the Codex plugin requires native `nemo-relay mcp` support", + format_command(&relay.display().to_string(), &args) + )) + } +} + pub(super) fn require_host_cli( host: PluginHost, options: &PluginInstallOptions, @@ -330,6 +352,33 @@ pub(super) fn require_host_cli( .ok_or_else(|| format!("required `{cli}` CLI was not found on PATH")) } +pub(super) fn validate_codex_version( + options: &PluginInstallOptions, + runner: &dyn CommandRunner, +) -> Result<(), String> { + if options.dry_run { + return Ok(()); + } + let output = run_capture_command("codex", &["--version".into()], options, runner)?; + let version = parse_codex_version(&output.stdout).ok_or_else(|| { + format!( + "could not parse `codex --version` output {:?}; the Relay plugin requires codex-cli 0.143.0 or newer", + output.stdout.trim() + ) + })?; + if version >= Version::new(0, 143, 0) { + Ok(()) + } else { + Err(format!( + "codex-cli {version} is unsupported; the Relay plugin requires codex-cli 0.143.0 or newer" + )) + } +} + +fn parse_codex_version(raw: &str) -> Option { + Version::parse(raw.trim().strip_prefix("codex-cli ")?).ok() +} + pub(super) fn run_command( program: &str, args: &[String], @@ -448,6 +497,7 @@ impl CommandOutput { } pub(super) 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; @@ -457,6 +507,11 @@ pub(super) trait CommandRunner { pub(super) 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)) } diff --git a/crates/cli/src/plugin_install/marketplace.rs b/crates/cli/src/plugin_install/marketplace.rs index 0169fd435..5009dfe8d 100644 --- a/crates/cli/src/plugin_install/marketplace.rs +++ b/crates/cli/src/plugin_install/marketplace.rs @@ -3,11 +3,14 @@ //! Generated local marketplace and plugin manifest files. +use std::env; use std::fs; +use std::path::Path; use serde_json::{Value, json}; use crate::config::{CodingAgent, PluginHost}; +use crate::install_generation::{GENERATION_FILE_ENV, write_new_generation}; use crate::installer::generated_hooks; use super::state::{PluginInstallOptions, PluginLayout, remove_path, write_json}; @@ -16,11 +19,26 @@ use super::{MARKETPLACE_NAME, PLUGIN_NAME}; pub(super) fn write_plugin_marketplace( host: PluginHost, layout: &PluginLayout, + relay: &Path, + options: &PluginInstallOptions, +) -> Result<(), String> { + write_plugin_marketplace_for_generation(host, layout, relay, &layout.generation_fence, options) +} + +pub(super) fn write_plugin_marketplace_for_generation( + host: PluginHost, + layout: &PluginLayout, + relay: &Path, + active_generation_fence: &Path, options: &PluginInstallOptions, ) -> Result<(), String> { if options.dry_run { println!("write {}", layout.marketplace_manifest.display()); println!("write {}", layout.plugin_manifest.display()); + if matches!(host, PluginHost::Codex) { + println!("write {}", layout.mcp_config.display()); + println!("write {}", layout.generation_fence.display()); + } if plugin_has_hooks_template(host) { println!("write {}", layout.hooks_path.display()); } @@ -41,8 +59,14 @@ pub(super) fn write_plugin_marketplace( } write_json(&layout.marketplace_manifest, &marketplace_manifest(host))?; write_json(&layout.plugin_manifest, &plugin_manifest(host))?; + if matches!(host, PluginHost::Codex) { + write_new_generation(&layout.generation_fence)?; + } + if let Some(mcp_config) = plugin_mcp_config(host, relay, active_generation_fence)? { + write_json(&layout.mcp_config, &mcp_config)?; + } if plugin_has_hooks_template(host) { - write_json(&layout.hooks_path, &plugin_hooks(host))?; + write_json(&layout.hooks_path, &plugin_hooks(host, relay))?; } Ok(()) } @@ -89,7 +113,9 @@ pub(super) fn marketplace_manifest(host: PluginHost) -> Value { 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::Codex => { + "Native Relay gateway lifecycle and Codex hooks for complete local observability." + } PluginHost::ClaudeCode => { "Claude Code hooks that forward canonical lifecycle payloads to nemo-relay." } @@ -114,10 +140,11 @@ pub(super) fn plugin_manifest(host: PluginHost) -> Value { "keywords": keywords }); if matches!(host, PluginHost::Codex) { + manifest["mcpServers"] = json!("./.mcp.json"); 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.", + "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"], @@ -129,11 +156,45 @@ pub(super) fn plugin_manifest(host: PluginHost) -> Value { manifest } -pub(super) fn plugin_hooks(host: PluginHost) -> Value { - match host { - PluginHost::Codex => { - generated_hooks(CodingAgent::Codex, "nemo-relay plugin-shim hook codex") +pub(super) fn plugin_mcp_config( + host: PluginHost, + relay: &Path, + generation_fence: &Path, +) -> Result, String> { + if !matches!(host, PluginHost::Codex) { + return Ok(None); + } + let generation_fence = absolute_or_self(generation_fence); + Ok(Some(json!({ + "nemo-relay": { + "command": relay, + "args": ["mcp"], + "env": { + "NEMO_RELAY_GATEWAY_BIND": "127.0.0.1:47632", + (GENERATION_FILE_ENV): generation_fence + }, + "env_vars": plugin_mcp_env_vars()?, + "required": true, + "startup_timeout_sec": 20 } + }))) +} + +fn absolute_or_self(path: &Path) -> std::path::PathBuf { + if path.is_absolute() { + return path.to_owned(); + } + env::current_dir() + .map(|current| current.join(path)) + .unwrap_or_else(|_| path.to_owned()) +} + +pub(super) fn plugin_hooks(host: PluginHost, relay: &Path) -> Value { + match host { + PluginHost::Codex => generated_hooks( + CodingAgent::Codex, + &crate::plugin_shim::codex_plugin_hook_command(relay), + ), PluginHost::ClaudeCode => generated_hooks( CodingAgent::ClaudeCode, "nemo-relay plugin-shim hook claude", @@ -142,10 +203,22 @@ pub(super) fn plugin_hooks(host: PluginHost) -> Value { } } +pub(super) fn plugin_mcp_env_vars() -> Result, String> { + let environment = env::vars_os().filter_map(|(name, _)| name.into_string().ok()); + let config = crate::config::user_plugin_runtime_config().map_err(|error| error.to_string())?; + Ok(plugin_mcp_env_vars_from(environment, config.as_ref())) +} + +pub(super) fn plugin_mcp_env_vars_from( + environment: impl IntoIterator, + config: Option<&Value>, +) -> Vec { + crate::mcp_environment::forwarded_names(environment, config) +} + pub(super) fn plugin_has_hooks_template(host: PluginHost) -> bool { match host { - PluginHost::Codex => false, - PluginHost::ClaudeCode => true, + PluginHost::Codex | 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 index 886e12b2c..25bc304c1 100644 --- a/crates/cli/src/plugin_install/mod.rs +++ b/crates/cli/src/plugin_install/mod.rs @@ -5,9 +5,11 @@ mod host; mod marketplace; +mod operation_lock; mod setup; mod state; +use std::fs; use std::path::{Path, PathBuf}; use std::process::ExitCode; use std::sync::mpsc::{self, Receiver}; @@ -18,16 +20,21 @@ use serde_json::{Value, json}; use crate::config::{InstallCommand, PluginHost, UninstallCommand}; use crate::error::CliError; +use crate::install_generation::{GENERATION_FILE_NAME, GenerationRetirement, InstallGeneration}; 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, + run_host_plugin_removal, validate_relay_mcp, validate_relay_plugin_shim, }; -use marketplace::{marketplace_manifest, plugin_manifest, write_plugin_marketplace}; +use marketplace::{ + marketplace_manifest, plugin_hooks, plugin_manifest, plugin_mcp_config, + write_plugin_marketplace, write_plugin_marketplace_for_generation, +}; +use operation_lock::{DEFAULT_OPERATION_LOCK_TIMEOUT, PluginOperationLock}; use setup::{ - PluginSetupRunner, RealPluginSetupRunner, run_plugin_doctor, run_plugin_doctor_json, - run_plugin_setup, run_plugin_uninstall, + PluginSetupRunner, PluginSetupSnapshot, RealPluginSetupRunner, run_plugin_doctor, + run_plugin_doctor_json, run_plugin_setup, run_plugin_uninstall, }; use state::{ CanonicalizeOrSelf, HostRegistrationProgress, HostSelectionMode, PluginInstallOptions, @@ -41,6 +48,18 @@ 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); +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 @@ -132,6 +151,7 @@ fn spawn_default_host_plugin_readiness( std::thread::spawn(move || { let options = PluginInstallOptions { install_dir, + operation_lock_dir: PathBuf::new(), force: false, dry_run: false, skip_doctor: true, @@ -190,11 +210,17 @@ fn failed_host_plugin_readiness( } pub(crate) fn install(command: InstallCommand) -> 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, @@ -208,11 +234,17 @@ pub(crate) fn install(command: InstallCommand) -> Result { } pub(crate) fn uninstall(command: UninstallCommand) -> 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, @@ -234,6 +266,7 @@ pub(crate) fn doctor( 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, @@ -353,55 +386,208 @@ fn install_host( 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: PluginHost, + options: &PluginInstallOptions, + runner: &dyn CommandRunner, + setup_runner: &dyn PluginSetupRunner, + lock_timeout: Duration, +) -> Result<(), String> { + let _operation_lock = (!options.dry_run) + .then(|| { + PluginOperationLock::acquire( + host, + &options.operation_lock_dir, + &options.install_dir, + lock_timeout, + ) + }) + .transpose()?; + install_host_locked(host, options, runner, setup_runner) +} + +fn install_host_locked( + 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)?; + if matches!(host, PluginHost::Codex) { + validate_relay_mcp(&relay, options, runner)?; + } require_host_cli(host, options, runner)?; + if matches!(host, PluginHost::Codex) { + host::validate_codex_version(options, runner)?; + } let layout = PluginLayout::new(host, &options.install_dir); - if options.force { + let codex_preflight = if !options.dry_run && matches!(host, PluginHost::Codex) { + Some(prepare_codex_install(&layout, options, runner)?) + } else { + None + }; + if !options.force + && codex_preflight + .as_ref() + .is_some_and(|preflight| preflight.previous_install_exists) + { + return Err(existing_codex_install_requires_force_error()); + } + let mut force_snapshot = None; + let staged = if options.force && !options.dry_run && matches!(host, PluginHost::Codex) { + let preflight = codex_preflight.expect("Codex force install has preflight state"); + let staged = stage_plugin_marketplace(host, &relay, &layout, options)?; + match begin_force_replacement(host, &layout, preflight, options, runner, setup_runner) { + Ok(mut snapshot) => { + if let Err(error) = setup_runner.refresh_gateway(host) { + 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() { + if !options.dry_run && matches!(host, PluginHost::Codex) { + setup_runner.refresh_gateway(host)?; + } force_cleanup_existing_install(host, &layout, options, runner, setup_runner)?; } - write_plugin_marketplace(host, &layout, options)?; + if let Some(staged) = staged.as_ref() { + if let Err(error) = staged.promote(&layout) { + 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; + staged.cleanup(); + } else { + write_plugin_marketplace(host, &layout, &relay, 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() - )); + let _replacement_retirement = if force_snapshot.is_some() { + match retire_replacement_before_rollback(host, &layout, options, setup_runner) { + 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 setup_attempted = false; + let mut setup_installed = false; let result = (|| { - run_host_marketplace_registration(host, &layout, options, runner)?; + run_host_marketplace_registration(host, &layout.marketplace_root, 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)?; + if !matches!(host, PluginHost::Codex) { + setup_installed = true; + } + run_plugin_setup(host, &layout, options, setup_runner)?; + setup_installed = true; mark_plugin_setup_installed(host, &layout, options)?; if !options.skip_doctor { - run_plugin_doctor(host, options, setup_runner)?; + run_plugin_doctor(host, &layout.plugin_root, options, setup_runner)?; } Ok(()) })(); if let Err(error) = result { - if let Err(rollback_error) = rollback_install( + let replacement_may_be_live = force_snapshot.is_some() || registration.host_plugin_added; + let _replacement_retirement = if replacement_may_be_live { + match retire_replacement_before_rollback(host, &layout, options, setup_runner) { + 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_attempted, + 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 failed to roll back install: {rollback_error}" + "{error}; additionally {}", + rollback_errors.join("; ") )); } return Err(error); } + if let Some(snapshot) = force_snapshot { + snapshot.commit(); + } println!( "installed {} plugin marketplace at {}", host_label(host), @@ -416,9 +602,146 @@ fn uninstall_host( 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: PluginHost, + options: &PluginInstallOptions, + runner: &dyn CommandRunner, + setup_runner: &dyn PluginSetupRunner, + lock_timeout: Duration, +) -> Result<(), String> { + let _operation_lock = (!options.dry_run) + .then(|| { + PluginOperationLock::acquire( + host, + &options.operation_lock_dir, + &options.install_dir, + lock_timeout, + ) + }) + .transpose()?; + uninstall_host_locked(host, options, runner, setup_runner) +} + +fn uninstall_host_locked( + host: PluginHost, + 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); + 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, + local_install_exists, + options, + runner, + setup_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() + ) + })?; + } uninstall_host_with_setup_override(host, options, runner, setup_runner, false) } +fn retire_installed_generation( + host: PluginHost, + plugin_root: &Path, + local_install_exists: bool, + options: &PluginInstallOptions, + runner: &dyn CommandRunner, + setup_runner: &dyn PluginSetupRunner, +) -> Result, String> { + if options.dry_run || !matches!(host, PluginHost::Codex) { + 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 { + return Err(missing_generation_fence_error(&generation_fence)); + } + } + let retirement = GenerationRetirement::acquire(&generation_fence) + .map_err(|cause| invalid_generation_fence_error(&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 { + return Err(missing_generation_fence_error(&generation_fence)); + } + setup_runner.refresh_gateway(host)?; + Ok(retirement) +} + +fn retire_replacement_before_rollback( + host: PluginHost, + layout: &PluginLayout, + options: &PluginInstallOptions, + setup_runner: &dyn PluginSetupRunner, +) -> Result, String> { + if options.dry_run || !matches!(host, PluginHost::Codex) { + return Ok(None); + } + let mut retirement = GenerationRetirement::acquire(&layout.generation_fence) + .map_err(|cause| invalid_generation_fence_error(&layout.generation_fence, &cause))? + .ok_or_else(|| missing_generation_fence_error(&layout.generation_fence))?; + setup_runner.refresh_gateway(host)?; + retirement.invalidate_for_replacement().map_err(|error| { + format!( + "failed to retire replacement MCP generation {} before rollback: {error}", + layout.generation_fence.display() + ) + })?; + Ok(Some(retirement)) +} + +fn existing_codex_install_requires_force_error() -> String { + "an existing fenced Codex plugin install was found; rerun `nemo-relay install codex --force` to replace it safely" + .into() +} + +fn missing_generation_fence_error(generation_fence: &Path) -> String { + unsafe_generation_fence_error(&format!("is missing at {}", generation_fence.display())) +} + +fn invalid_generation_fence_error(generation_fence: &Path, cause: &str) -> String { + unsafe_generation_fence_error(&format!( + "at {} is invalid or unreadable: {cause}", + generation_fence.display() + )) +} + +fn unsafe_generation_fence_error(problem: &str) -> String { + 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)" + ) +} + fn uninstall_host_with_setup_override( host: PluginHost, options: &PluginInstallOptions, @@ -446,12 +769,12 @@ fn uninstall_host_with_setup_override( 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)?; + 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(host)); @@ -479,9 +802,9 @@ fn doctor_host( } readiness.ok().then_some(()).ok_or_else(|| { format!( - "{} plugin doctor checks failed; run `nemo-relay install {} --force` to repair the installation", + "{} plugin doctor checks failed; remediation: {}", host_label(host), - host_arg(host) + readiness.remediation ) }) } @@ -522,6 +845,10 @@ fn collect_host_plugin_readiness( 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()) @@ -580,6 +907,38 @@ fn collect_host_plugin_readiness( validate_relay_plugin_shim(&relay, options, runner) .map(|_| "plugin-shim hook is supported".into()), ); + if let Some(plugin) = readiness.plugin.as_ref() { + readiness.push( + "Generated hooks", + generated_manifest_check( + &plugin.join("hooks").join("hooks.json"), + &plugin_hooks(host, &relay), + "hooks", + ), + ); + } + if matches!(host, PluginHost::Codex) { + 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::install_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 = plugin_mcp_config(host, &relay, &generation_fence) + .and_then(|expected| { + expected.ok_or_else(|| "Codex MCP configuration was not generated".into()) + }) + .and_then(|expected| generated_mcp_config_check(&mcp_config, &expected)); + readiness.push("Generated MCP server", check); + } + } } let host_cli_check = require_host_cli(host, options, runner); @@ -591,6 +950,18 @@ fn collect_host_plugin_readiness( .map_err(Clone::clone), ); if host_cli_check.is_ok() { + if matches!(host, PluginHost::Codex) { + let version = host::validate_codex_version(options, runner); + if version.is_err() { + readiness.remediation = + "upgrade Codex to codex-cli 0.143.0 or newer, then run `nemo-relay install codex --force`" + .into(); + } + readiness.push( + "Codex version", + version.map(|_| "codex-cli 0.143.0 or newer is installed".into()), + ); + } match host_registration_report(host, options, runner) { Ok(report) => { readiness.host_plugin_registered = Some(report.host_plugin_registered); @@ -621,7 +992,7 @@ fn collect_host_plugin_readiness( } } - match run_plugin_doctor_json(host, setup_runner) { + 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); @@ -679,6 +1050,47 @@ fn generated_manifest_check(path: &Path, expected: &Value, label: &str) -> Resul } } +fn generated_mcp_config_check(path: &Path, expected: &Value) -> 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())); + } + let expected_vars = expected["nemo-relay"]["env_vars"] + .as_array() + .into_iter() + .flatten() + .filter_map(Value::as_str) + .collect::>(); + let actual_vars = actual["nemo-relay"]["env_vars"] + .as_array() + .into_iter() + .flatten() + .filter_map(Value::as_str) + .collect::>(); + let missing = expected_vars + .difference(&actual_vars) + .copied() + .collect::>(); + if !missing.is_empty() { + return Err(format!( + "MCP server at {} is missing forwarded environment variables: {}; run `nemo-relay install codex --force`", + path.display(), + missing.join(", ") + )); + } + Err(format!( + "unexpected MCP server manifest contents at {}", + path.display() + )) +} + fn marketplace_manifest_path(host: PluginHost, root: &Path) -> PathBuf { match host { PluginHost::Codex => root @@ -698,6 +1110,448 @@ fn plugin_manifest_path(host: PluginHost, root: &Path) -> PathBuf { } } +fn plugin_mcp_config_path(root: &Path) -> PathBuf { + root.join(".mcp.json") +} + +struct StagedPluginMarketplace { + layout: PluginLayout, + parent: PathBuf, +} + +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 CodexInstallPreflight { + 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_codex_install( + layout: &PluginLayout, + options: &PluginInstallOptions, + runner: &dyn CommandRunner, +) -> Result { + let persisted = read_state(PluginHost::Codex, &options.install_dir); + let registration = host_registration_report(PluginHost::Codex, 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_install_exists = state_bytes.is_some() + || layout.marketplace_root.exists() + || plugin_registered + || marketplace_registered; + let generation_retirement = if previous_install_exists { + if !previous_generation_fence.exists() { + return Err(missing_generation_fence_error(&previous_generation_fence)); + } + Some( + GenerationRetirement::acquire(&previous_generation_fence) + .map_err(|cause| { + invalid_generation_fence_error(&previous_generation_fence, &cause) + })? + .ok_or_else(|| missing_generation_fence_error(&previous_generation_fence))?, + ) + } else { + None + }; + Ok(CodexInstallPreflight { + 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(self) { + 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() + ), + } + } + } +} + +fn stage_plugin_marketplace( + host: PluginHost, + relay: &Path, + target: &PluginLayout, + options: &PluginInstallOptions, +) -> Result { + let parent = options.install_dir.join(format!( + ".{}-install-stage-{}", + host_arg(host), + uuid::Uuid::now_v7() + )); + let layout = PluginLayout::new(host, &parent); + if let Err(error) = write_plugin_marketplace_for_generation( + host, + &layout, + relay, + &target.generation_fence, + options, + ) { + let _ = fs::remove_dir_all(&parent); + return Err(error); + } + Ok(StagedPluginMarketplace { layout, parent }) +} + +fn begin_force_replacement( + host: PluginHost, + layout: &PluginLayout, + preflight: CodexInstallPreflight, + options: &PluginInstallOptions, + runner: &dyn CommandRunner, + setup_runner: &dyn PluginSetupRunner, +) -> Result { + let CodexInstallPreflight { + 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)?; + let backup_parent = previous_marketplace_root + .parent() + .unwrap_or(&options.install_dir); + let backup_marketplace_root = backup_parent.join(format!( + ".{}-marketplace-backup-{}", + host_arg(host), + 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_arg(host), + 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 = 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() + ) + })?; + } + 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: PluginHost, + 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: PluginHost, + 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: PluginHost, layout: &PluginLayout, @@ -706,7 +1560,7 @@ fn force_cleanup_existing_install( setup_runner: &dyn PluginSetupRunner, ) -> Result<(), String> { if layout.state_path.exists() { - uninstall_host(host, options, runner, setup_runner)?; + uninstall_host_locked(host, options, runner, setup_runner)?; } else { let mut state = PluginState { marketplace_root: layout.marketplace_root.clone(), @@ -726,12 +1580,12 @@ fn rollback_install( host: PluginHost, layout: &PluginLayout, registration: HostRegistrationProgress, - setup_attempted: bool, + setup_installed: bool, options: &PluginInstallOptions, runner: &dyn CommandRunner, setup_runner: &dyn PluginSetupRunner, ) -> Result<(), String> { - if setup_attempted { + 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 { diff --git a/crates/cli/src/plugin_install/operation_lock.rs b/crates/cli/src/plugin_install/operation_lock.rs new file mode 100644 index 000000000..ceb306c64 --- /dev/null +++ b/crates/cli/src/plugin_install/operation_lock.rs @@ -0,0 +1,100 @@ +// 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::config::PluginHost; +use crate::file_io::{LockAttempt, try_lock_exclusive}; + +pub(super) const DEFAULT_OPERATION_LOCK_TIMEOUT: Duration = Duration::from_secs(5); +const LOCK_RETRY_INTERVAL: Duration = Duration::from_millis(25); + +pub(super) struct PluginOperationLock { + _global_file: File, + _root_file: File, +} + +impl PluginOperationLock { + pub(super) fn acquire( + host: PluginHost, + global_lock_dir: &Path, + install_dir: &Path, + timeout: Duration, + ) -> Result { + let deadline = Instant::now() + timeout; + let global_file = acquire_lock_file(host, global_lock_dir, deadline, "global")?; + let root_file = acquire_lock_file(host, install_dir, deadline, "install-root")?; + Ok(Self { + _global_file: global_file, + _root_file: root_file, + }) + } +} + +fn acquire_lock_file( + host: PluginHost, + directory: &Path, + deadline: Instant, + scope: &str, +) -> Result { + fs::create_dir_all(directory).map_err(|error| { + format!( + "failed to create plugin operation lock directory {}: {error}", + directory.display() + ) + })?; + let path = operation_lock_path(host, 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", + host_name(host), + 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(super) fn operation_lock_path(host: PluginHost, install_dir: &Path) -> PathBuf { + install_dir.join(format!(".nemo-relay-{}-operation.lock", host_name(host))) +} + +fn host_name(host: PluginHost) -> &'static str { + match host { + PluginHost::Codex => "codex", + PluginHost::ClaudeCode => "claude-code", + PluginHost::All => unreachable!("all is expanded before operation locking"), + } +} diff --git a/crates/cli/src/plugin_install/setup.rs b/crates/cli/src/plugin_install/setup.rs index b52d36ff0..2c4b3e985 100644 --- a/crates/cli/src/plugin_install/setup.rs +++ b/crates/cli/src/plugin_install/setup.rs @@ -6,12 +6,15 @@ use crate::config::{CodingAgent, PluginHost}; use crate::plugin_shim; use serde_json::Value; +use std::path::Path; use super::DEFAULT_GATEWAY_URL; use super::state::PluginInstallOptions; +use super::state::PluginLayout; pub(super) fn run_plugin_setup( host: PluginHost, + layout: &PluginLayout, options: &PluginInstallOptions, setup_runner: &dyn PluginSetupRunner, ) -> Result<(), String> { @@ -19,11 +22,12 @@ pub(super) fn run_plugin_setup( println!("{}", setup_action_description(host, "configure")); return Ok(()); } - setup_runner.setup(host, DEFAULT_GATEWAY_URL) + setup_runner.setup(host, DEFAULT_GATEWAY_URL, &layout.plugin_root) } pub(super) fn run_plugin_uninstall( host: PluginHost, + plugin_root: &Path, options: &PluginInstallOptions, setup_runner: &dyn PluginSetupRunner, ) -> Result<(), String> { @@ -31,11 +35,12 @@ pub(super) fn run_plugin_uninstall( println!("{}", setup_action_description(host, "restore")); return Ok(()); } - setup_runner.uninstall(host, DEFAULT_GATEWAY_URL) + setup_runner.uninstall(host, DEFAULT_GATEWAY_URL, plugin_root) } pub(super) fn run_plugin_doctor( host: PluginHost, + plugin_root: &Path, options: &PluginInstallOptions, setup_runner: &dyn PluginSetupRunner, ) -> Result<(), String> { @@ -43,25 +48,24 @@ pub(super) fn run_plugin_doctor( println!("{}", setup_action_description(host, "doctor")); return Ok(()); } - setup_runner.doctor(host, DEFAULT_GATEWAY_URL) + setup_runner.doctor(host, DEFAULT_GATEWAY_URL, plugin_root) } pub(super) fn run_plugin_doctor_json( host: PluginHost, + plugin_root: &Path, setup_runner: &dyn PluginSetupRunner, ) -> Result { - setup_runner.doctor_json(host, DEFAULT_GATEWAY_URL) + setup_runner.doctor_json(host, DEFAULT_GATEWAY_URL, plugin_root) } 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() + "configure Codex provider and trust plugin-owned hooks".into() } - (PluginHost::Codex, "restore") => { - "restore Codex provider and generated hook configuration".into() - } - (PluginHost::Codex, "doctor") => "check Codex provider and generated hooks".into(), + (PluginHost::Codex, "restore") => "remove Codex provider and plugin hook trust".into(), + (PluginHost::Codex, "doctor") => "check Codex provider and plugin-owned hooks".into(), (PluginHost::ClaudeCode, "configure") => { "enable Claude Code provider routing through NeMo Relay".into() } @@ -75,48 +79,126 @@ pub(super) fn setup_action_description(host: PluginHost, action: &str) -> String } 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; + fn snapshot(&self, _host: PluginHost) -> Result, String> { + Ok(None) + } + + fn restore_snapshot(&self, _snapshot: &PluginSetupSnapshot) -> Result<(), String> { + Ok(()) + } + + fn refresh_gateway(&self, _host: PluginHost) -> Result<(), String> { + Ok(()) + } + + fn setup(&self, host: PluginHost, gateway_url: &str, plugin_root: &Path) -> Result<(), String>; + fn uninstall( + &self, + host: PluginHost, + gateway_url: &str, + plugin_root: &Path, + ) -> Result<(), String>; + fn doctor(&self, host: PluginHost, gateway_url: &str, plugin_root: &Path) + -> Result<(), String>; + fn doctor_json( + &self, + host: PluginHost, + gateway_url: &str, + plugin_root: &Path, + ) -> Result; } pub(super) struct RealPluginSetupRunner; +pub(super) enum PluginSetupSnapshot { + Codex(plugin_shim::CodexSetupSnapshot), + #[cfg(test)] + Mock, +} + impl PluginSetupRunner for RealPluginSetupRunner { - fn setup(&self, host: PluginHost, gateway_url: &str) -> Result<(), String> { + fn snapshot(&self, host: PluginHost) -> Result, String> { match host { - PluginHost::Codex => plugin_shim::install_codex_plugin(gateway_url), + PluginHost::Codex => plugin_shim::snapshot_codex_setup() + .map(PluginSetupSnapshot::Codex) + .map(Some), + PluginHost::ClaudeCode => Ok(None), + PluginHost::All => unreachable!("all is expanded before plugin setup"), + } + } + + fn restore_snapshot(&self, snapshot: &PluginSetupSnapshot) -> Result<(), String> { + match snapshot { + PluginSetupSnapshot::Codex(snapshot) => plugin_shim::restore_codex_setup(snapshot), + #[cfg(test)] + PluginSetupSnapshot::Mock => Ok(()), + } + } + + fn refresh_gateway(&self, host: PluginHost) -> Result<(), String> { + match host { + PluginHost::Codex => plugin_shim::stop_codex_gateway(), + PluginHost::ClaudeCode => Ok(()), + PluginHost::All => unreachable!("all is expanded before plugin setup"), + } + } + + fn setup(&self, host: PluginHost, gateway_url: &str, plugin_root: &Path) -> Result<(), String> { + match host { + PluginHost::Codex => plugin_shim::install_codex_plugin(gateway_url, plugin_root), 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> { + fn uninstall( + &self, + host: PluginHost, + gateway_url: &str, + plugin_root: &Path, + ) -> Result<(), String> { match host { - PluginHost::Codex => plugin_shim::uninstall_codex_plugin(gateway_url), + PluginHost::Codex => plugin_shim::uninstall_codex_plugin(gateway_url, plugin_root), 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> { + fn doctor( + &self, + host: PluginHost, + gateway_url: &str, + plugin_root: &Path, + ) -> Result<(), String> { match host { - PluginHost::Codex => plugin_shim::doctor_plugin(CodingAgent::Codex, gateway_url), + PluginHost::Codex => { + plugin_shim::doctor_plugin(CodingAgent::Codex, gateway_url, plugin_root) + } PluginHost::ClaudeCode => { - plugin_shim::doctor_plugin(CodingAgent::ClaudeCode, gateway_url) + plugin_shim::doctor_plugin(CodingAgent::ClaudeCode, gateway_url, plugin_root) } PluginHost::All => unreachable!("all is expanded before plugin doctor"), } } - fn doctor_json(&self, host: PluginHost, gateway_url: &str) -> Result { + fn doctor_json( + &self, + host: PluginHost, + gateway_url: &str, + plugin_root: &Path, + ) -> Result { match host { - PluginHost::Codex => plugin_shim::doctor_plugin_json(CodingAgent::Codex, gateway_url), + PluginHost::Codex => { + plugin_shim::doctor_plugin_json(CodingAgent::Codex, gateway_url, plugin_root) + } PluginHost::ClaudeCode => { - plugin_shim::doctor_plugin_json(CodingAgent::ClaudeCode, gateway_url) + plugin_shim::doctor_plugin_json(CodingAgent::ClaudeCode, gateway_url, plugin_root) } PluginHost::All => unreachable!("all is expanded before plugin doctor"), } } } + +#[cfg(test)] +#[path = "../../tests/coverage/plugin_install_setup_tests.rs"] +mod tests; diff --git a/crates/cli/src/plugin_install/state.rs b/crates/cli/src/plugin_install/state.rs index 2ceaed749..e40e86fdc 100644 --- a/crates/cli/src/plugin_install/state.rs +++ b/crates/cli/src/plugin_install/state.rs @@ -11,12 +11,14 @@ use std::path::{Path, PathBuf}; use serde_json::{Value, json}; use crate::config::PluginHost; +use crate::install_generation::GENERATION_FILE_NAME; use super::{PLUGIN_NAME, host_arg}; #[derive(Debug, Clone)] pub(super) struct PluginInstallOptions { pub(super) install_dir: PathBuf, + pub(super) operation_lock_dir: PathBuf, pub(super) force: bool, pub(super) dry_run: bool, pub(super) skip_doctor: bool, @@ -47,6 +49,8 @@ pub(super) struct PluginLayout { 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) hooks_path: PathBuf, pub(super) state_path: PathBuf, } @@ -70,6 +74,8 @@ impl PluginLayout { PluginHost::ClaudeCode => plugin_root.join(".claude-plugin").join("plugin.json"), PluginHost::All => unreachable!("all is expanded before layout resolution"), }; + let mcp_config = plugin_root.join(".mcp.json"); + let generation_fence = plugin_root.join(GENERATION_FILE_NAME); let hooks_path = plugin_root.join("hooks").join("hooks.json"); let state_path = state_path(host, install_dir); Self { @@ -78,6 +84,8 @@ impl PluginLayout { marketplace_manifest, plugin_root, plugin_manifest, + mcp_config, + generation_fence, hooks_path, state_path, } diff --git a/crates/cli/src/plugin_shim/codex.rs b/crates/cli/src/plugin_shim/codex.rs index 85933d50f..f9c2e5d0a 100644 --- a/crates/cli/src/plugin_shim/codex.rs +++ b/crates/cli/src/plugin_shim/codex.rs @@ -3,86 +3,653 @@ //! Codex-specific plugin setup, provider routing, and hook configuration. +use std::collections::BTreeSet; +use std::env; use std::fs; -use std::path::Path; +use std::path::{Path, PathBuf}; 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 crate::installer::generated_hooks; +#[cfg(test)] +use crate::installer::merge_hooks; +use super::codex_app_server::{CodexAppServerClient, CodexHookMetadata, CodexHooksClient}; 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, + portable_executable_path, 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"); +pub(super) const CODEX_PLUGIN_ID: &str = "nemo-relay-plugin@nemo-relay-local"; +pub(super) 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("; ")) + } +} + +pub(super) fn install_codex( + gateway_url: &str, + plugin_hooks_path: &Path, +) -> Result { + let expected_command = expected_plugin_hook_command()?; + 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(super) 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 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); + 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()); - println!("updated {}", hooks_path.display()); - println!("Codex Relay sidecar startup is hook-supervised; no daemon was installed."); + 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(super) fn uninstall_codex(installed_gateway_url: &str) -> Result { - let codex_dir = home_dir()?.join(".codex"); +pub(super) 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(super) 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 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); + 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 hook-supervised sidecar setup."); + println!("removed Codex Relay provider and plugin hook trust."); Ok(ExitCode::SUCCESS) } +const GENERATED_CODEX_HOOK_EVENTS: &[(&str, &str)] = &[ + ("sessionstart", "SessionStart"), + ("userpromptsubmit", "UserPromptSubmit"), + ("pretooluse", "PreToolUse"), + ("posttooluse", "PostToolUse"), + ("permissionrequest", "PermissionRequest"), + ("subagentstart", "SubagentStart"), + ("subagentstop", "SubagentStop"), + ("stop", "Stop"), + ("precompact", "PreCompact"), + ("postcompact", "PostCompact"), +]; + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub(super) struct CodexHookTrustReport { + trusted: Vec, + untrusted: Vec, + modified: Vec, + disabled: Vec, + missing_required: Vec, + duplicate_required: Vec, +} + +impl CodexHookTrustReport { + pub(super) 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(super) 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(super) 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(super) fn empty_codex_hook_trust_report() -> CodexHookTrustReport { + CodexHookTrustReport { + missing_required: GENERATED_CODEX_HOOK_EVENTS + .iter() + .map(|(_, display)| (*display).to_string()) + .collect(), + ..CodexHookTrustReport::default() + } +} + +pub(super) fn codex_hook_trust_report( + plugin_hooks_path: &Path, +) -> Result { + let home = home_dir()?; + let expected_command = expected_plugin_hook_command()?; + 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(super) 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(super) 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" + && GENERATED_CODEX_HOOK_EVENTS + .iter() + .any(|(event, _)| normalize_hook_event(&hook.event_name) == *event) + }) + .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(super) 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) +} + +pub(super) fn expected_plugin_hook_command() -> Result { + let relay = current_exe()?; + let relay = relay.canonicalize().unwrap_or(relay); + let relay = portable_executable_path(relay); + Ok(codex_plugin_hook_command(&relay)) +} + +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(super) 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 = GENERATED_CODEX_HOOK_EVENTS + .iter() + .filter(|(normalized, _)| { + !hooks + .iter() + .any(|hook| normalize_hook_event(&hook.event_name) == *normalized) + }) + .map(|(_, display)| (*display).to_string()) + .collect(); + report.duplicate_required = GENERATED_CODEX_HOOK_EVENTS + .iter() + .filter(|(normalized, _)| { + hooks + .iter() + .filter(|hook| normalize_hook_event(&hook.event_name) == *normalized) + .count() + > 1 + }) + .map(|(_, display)| (*display).to_string()) + .collect(); + report +} + +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(super) fn prepare_codex_config(path: &Path) -> Result<(), String> { let raw = read_optional_text(path)?; raw.parse::() @@ -205,6 +772,22 @@ fn remove_codex_config_without_backup( } } +pub(super) 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(super) fn install_codex_hooks(path: &Path, gateway_url: &str) -> Result<(), String> { let relay = current_exe()?; let command = codex_hook_command(gateway_url); @@ -255,8 +838,16 @@ pub(super) fn remove_managed_codex_hook_groups( .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.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() }) @@ -267,20 +858,12 @@ pub(super) fn remove_managed_codex_hook_groups( } } -pub(super) fn managed_codex_hook_group_for_relay( - group: &Value, +fn managed_codex_hook_for_relay( + hook: &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) - { + 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 { @@ -291,13 +874,26 @@ pub(super) fn managed_codex_hook_group_for_relay( } 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) - )) + || legacy_relay_hook_command(command) +} + +fn legacy_relay_hook_command(command: &str) -> bool { + let Some((program, arguments)) = command.split_once(" plugin-shim hook codex") 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(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; @@ -311,6 +907,7 @@ pub(super) fn hook_config_contains_generated_groups(existing: &Value, generated: }) } +#[cfg(test)] pub(super) fn generated_event_contains_group(config: &Value, event: &str, group: &Value) -> bool { config .get("hooks") @@ -340,6 +937,7 @@ pub(super) fn codex_config_doc_has_managed_install(doc: &DocumentMut, gateway_ur && feature_hooks_enabled(doc) == Some(true) } +#[cfg(test)] pub(super) fn codex_provider_gateway_url(path: &Path) -> Option { let raw = fs::read_to_string(path).ok()?; let doc = raw.parse::().ok()?; @@ -491,7 +1089,7 @@ pub(super) fn remove_table_item_if_bool( } pub(super) fn codex_provider_installed(gateway_url: &str) -> bool { - let Ok(path) = home_dir().map(|home| home.join(".codex").join("config.toml")) else { + let Ok(path) = codex_home_dir().map(|home| home.join("config.toml")) else { return false; }; let Ok(raw) = fs::read_to_string(path) else { @@ -503,19 +1101,17 @@ pub(super) fn codex_provider_installed(gateway_url: &str) -> bool { 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 codex_hooks_installed(path: &Path) -> Result { + let value = read_json_object(path)?; + let generated = generated_hooks(CodingAgent::Codex, &expected_plugin_hook_command()?); + Ok(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 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(super) fn shell_quote(path: &Path) -> String { @@ -575,6 +1171,18 @@ pub(super) fn codex_hook_command(gateway_url: &str) -> String { ) } +pub(super) fn codex_plugin_hook_command(relay: &Path) -> String { + codex_plugin_hook_command_for_platform(relay, cfg!(windows)) +} + +pub(super) fn codex_plugin_hook_command_for_platform(relay: &Path, windows: bool) -> String { + format!( + "{} plugin-shim hook codex --gateway-url {}", + shell_quote_for_platform(relay, windows), + shell_quote_arg_for_platform(super::DEFAULT_URL, windows) + ) +} + #[cfg(test)] pub(super) fn codex_hook_command_for_platform( relay: &Path, diff --git a/crates/cli/src/plugin_shim/codex_app_server.rs b/crates/cli/src/plugin_shim/codex_app_server.rs new file mode 100644 index 000000000..3c72e043a --- /dev/null +++ b/crates/cli/src/plugin_shim/codex_app_server.rs @@ -0,0 +1,242 @@ +// 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(super) struct CodexHookMetadata { + pub(super) key: String, + pub(super) event_name: String, + pub(super) handler_type: String, + pub(super) command: Option, + pub(super) source_path: String, + pub(super) source: String, + #[serde(default)] + pub(super) plugin_id: Option, + pub(super) enabled: bool, + pub(super) current_hash: String, + pub(super) trust_status: String, +} + +pub(super) 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(super) struct CodexAppServerClient { + child: Child, + stdin: ChildStdin, + messages: Receiver>, + next_id: u64, +} + +impl CodexAppServerClient { + pub(super) 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(super) 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}") +} + +#[cfg(not(windows))] +fn codex_app_server_command() -> Command { + let mut command = Command::new("codex"); + command.arg("app-server"); + command +} + +#[cfg(windows)] +fn codex_app_server_command() -> Command { + let mut command = Command::new( + std::env::var_os("COMSPEC").unwrap_or_else(|| std::ffi::OsString::from("cmd.exe")), + ); + command.args(["/d", "/s", "/c", "codex app-server"]); + command +} diff --git a/crates/cli/src/plugin_shim/mod.rs b/crates/cli/src/plugin_shim/mod.rs index 20a08c177..5b1ade0d8 100644 --- a/crates/cli/src/plugin_shim/mod.rs +++ b/crates/cli/src/plugin_shim/mod.rs @@ -5,35 +5,43 @@ mod claude; mod codex; +mod codex_app_server; mod command; mod shared; +pub(crate) use codex::{CodexSetupSnapshot, restore_codex_setup, snapshot_codex_setup}; +pub(crate) use shared::portable_executable_path; +#[cfg(test)] +pub(crate) use shared::strip_windows_verbatim_prefix; + pub(crate) use command::PluginShimCommand; use std::env; use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; use std::process::{Command, ExitCode}; use serde_json::{Value, json}; use claude::{claude_provider, claude_settings_base_url}; +use codex::{codex_hook_trust_report, empty_codex_hook_trust_report}; use codex::{codex_hooks_installed, codex_provider_installed, install_codex, uninstall_codex}; use command::{ PluginShimDoctorCommand, PluginShimInstallCommand, PluginShimProviderAction, PluginShimProviderCommand, PluginShimSubcommand, PluginShimUninstallCommand, }; +#[cfg(test)] +use shared::MAX_HOOK_RESPONSE_BYTES; use shared::{ - ExecOrStatus, current_exe, fail_closed, gateway_url, healthz, plugin_idle_timeout, post_hook, - print_check, print_info, relay_binary, + ExecOrStatus, HookForwardError, current_exe, fail_closed, gateway_url, healthz, + plugin_idle_timeout, post_hook, print_check, print_info, relay_binary, }; -use crate::config::CodingAgent; +use crate::config::{CodingAgent, ServerArgs}; 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(super) use crate::sidecar::{DEFAULT_BIND, DEFAULT_URL}; +const DEFAULT_HOOK_STDIN_BYTES: usize = crate::config::DEFAULT_MAX_HOOK_PAYLOAD_BYTES; pub(crate) fn run(command: PluginShimCommand) -> Result { match command.command { @@ -52,29 +60,64 @@ fn serve(args: Vec) -> Result { 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.env( + "NEMO_RELAY_PLUGIN_IDLE_TIMEOUT_SECS", + plugin_idle_timeout()?.as_secs().to_string(), + ); 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 url = gateway_url(agent, explicit_gateway_url); + let codex_launch = if matches!(agent, CodingAgent::Codex) { + Some(crate::sidecar::loopback_bind(&url).and_then(|bind| { + crate::sidecar::resolve_codex_gateway(&ServerArgs::default(), bind) + .map_err(|error| error.to_string()) + })) + } else { + None + }; + let max_hook_payload_bytes = codex_launch + .as_ref() + .and_then(|launch| launch.as_ref().ok()) + .map_or(DEFAULT_HOOK_STDIN_BYTES, |launch| { + launch.max_hook_payload_bytes + }); let mut input = std::io::stdin(); let mut output = std::io::stdout(); hook_with_io( - agent, - explicit_gateway_url, + HookInvocation { + agent, + gateway_url: Some(&url), + max_payload_bytes: max_hook_payload_bytes, + preflight_gateway: matches!(agent, CodingAgent::Codex), + }, &mut input, &mut output, - shared::ensure_sidecar, + |agent, url| match codex_launch.as_ref() { + Some(Ok(launch)) => launch.gateway.ensure().map(|_| ()), + Some(Err(error)) => Err(error.clone()), + None => crate::sidecar::loopback_bind(url) + .and_then(|bind| crate::sidecar::GatewaySpec::new(agent, bind).ensure()) + .map(|_| ()), + }, post_hook, fail_closed, ) } -fn hook_with_io( +#[derive(Clone, Copy)] +struct HookInvocation<'a> { agent: CodingAgent, - explicit_gateway_url: Option<&str>, + gateway_url: Option<&'a str>, + max_payload_bytes: usize, + preflight_gateway: bool, +} + +fn hook_with_io( + invocation: HookInvocation<'_>, input: &mut R, output: &mut W, mut ensure_sidecar: E, @@ -84,20 +127,44 @@ fn hook_with_io( where R: Read, W: Write, - E: FnMut(CodingAgent, &str), - P: FnMut(CodingAgent, &str, &[u8]) -> Result, String>, + E: FnMut(CodingAgent, &str) -> Result<(), String>, + P: FnMut(CodingAgent, &str, &[u8]) -> Result, HookForwardError>, F: FnOnce() -> bool, { - let url = gateway_url(agent, explicit_gateway_url); + let agent = invocation.agent; + let url = gateway_url(agent, invocation.gateway_url); + let fail_closed = fail_closed(); let mut payload = Vec::new(); - input - .read_to_end(&mut payload) - .map_err(|error| format!("failed to read hook payload: {error}"))?; + if let Err(error) = read_hook_payload(input, &mut payload, invocation.max_payload_bytes) { + if fail_closed { + return Err(error); + } + eprintln!("{error}"); + return Ok(ExitCode::SUCCESS); + } if payload.iter().all(u8::is_ascii_whitespace) { payload = b"{}".to_vec(); } - ensure_sidecar(agent, &url); - match post_hook(agent, &url, &payload) { + let preflight = if invocation.preflight_gateway { + ensure_sidecar(agent, &url) + } else { + Ok(()) + }; + let forwarded = match preflight { + Err(error) => Err(HookForwardError::not_retryable(format!( + "gateway identity preflight failed: {error}" + ))), + Ok(()) => match post_hook(agent, &url, &payload) { + Err(error) if error.is_retryable() => match ensure_sidecar(agent, &url) { + Ok(()) => post_hook(agent, &url, &payload), + Err(start_error) => Err(HookForwardError::not_retryable(format!( + "{error}; sidecar bootstrap failed: {start_error}" + ))), + }, + result => result, + }, + }; + match forwarded { Ok(body) => { if !body.is_empty() { output @@ -106,7 +173,7 @@ where } Ok(ExitCode::SUCCESS) } - Err(error) if fail_closed() => Err(error), + Err(error) if fail_closed => Err(error.to_string()), Err(error) => { eprintln!("{error}"); Ok(ExitCode::SUCCESS) @@ -114,9 +181,24 @@ where } } +fn read_hook_payload( + input: &mut R, + payload: &mut Vec, + limit: usize, +) -> Result<(), String> { + input + .take(limit.saturating_add(1) as u64) + .read_to_end(payload) + .map_err(|error| format!("failed to read hook payload: {error}"))?; + if payload.len() > limit { + return Err(format!("hook payload exceeds the {limit}-byte limit")); + } + Ok(()) +} + fn install(command: PluginShimInstallCommand) -> Result { match command.agent { - CodingAgent::Codex => install_codex(&command.gateway_url), + CodingAgent::Codex => install_codex(&command.gateway_url, &plugin_hooks_path_from_env()?), other => Err(format!( "plugin install supports codex, got {}", other.as_arg() @@ -126,7 +208,7 @@ fn install(command: PluginShimInstallCommand) -> Result { fn uninstall(command: PluginShimUninstallCommand) -> Result { match command.agent { - CodingAgent::Codex => uninstall_codex(&command.gateway_url), + CodingAgent::Codex => uninstall_codex(&command.gateway_url, &plugin_hooks_path_from_env()?), other => Err(format!( "plugin uninstall supports codex, got {}", other.as_arg() @@ -145,19 +227,32 @@ fn provider(command: PluginShimProviderCommand) -> Result { } fn doctor(command: PluginShimDoctorCommand) -> Result { - Ok(if doctor_ok(command.agent, &command.gateway_url)? { - ExitCode::SUCCESS - } else { - ExitCode::FAILURE - }) + let plugin_hooks = matches!(command.agent, CodingAgent::Codex) + .then(plugin_hooks_path_from_env) + .transpose()?; + Ok( + if doctor_ok(command.agent, &command.gateway_url, plugin_hooks.as_deref())? { + ExitCode::SUCCESS + } else { + ExitCode::FAILURE + }, + ) } -pub(crate) fn install_codex_plugin(gateway_url: &str) -> Result<(), String> { - install_codex(gateway_url).map(|_| ()) +pub(crate) fn install_codex_plugin(gateway_url: &str, plugin_root: &Path) -> Result<(), String> { + install_codex(gateway_url, &plugin_root.join("hooks").join("hooks.json")).map(|_| ()) } -pub(crate) fn uninstall_codex_plugin(gateway_url: &str) -> Result<(), String> { - uninstall_codex(gateway_url).map(|_| ()) +pub(crate) fn stop_codex_gateway() -> Result<(), String> { + crate::sidecar::stop_owned_sidecar(CodingAgent::Codex) +} + +pub(crate) fn codex_plugin_hook_command(relay: &std::path::Path) -> String { + codex::codex_plugin_hook_command(relay) +} + +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> { @@ -168,18 +263,30 @@ 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)? { +pub(crate) fn doctor_plugin( + agent: CodingAgent, + gateway_url: &str, + plugin_root: &Path, +) -> Result<(), String> { + if doctor_ok( + agent, + gateway_url, + Some(&plugin_root.join("hooks").join("hooks.json")), + )? { Ok(()) } else { Err(format!("{} plugin doctor checks failed", agent.as_arg())) } } -pub(crate) fn doctor_plugin_json(agent: CodingAgent, gateway_url: &str) -> Result { +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) = match agent { + let (checks, ok, codex_trust) = match agent { CodingAgent::ClaudeCode => { let provider = claude_settings_base_url().as_deref() == Some(gateway_url); ( @@ -189,19 +296,29 @@ pub(crate) fn doctor_plugin_json(agent: CodingAgent, gateway_url: &str) -> Resul "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(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": hooks, + "codex_hooks_trusted": hooks_trusted }), - plugin_binary && provider && hooks, + plugin_binary && provider && hooks && hooks_trusted, + Some(trust), ) } other => { @@ -211,18 +328,28 @@ pub(crate) fn doctor_plugin_json(agent: CodingAgent, gateway_url: &str) -> Resul )); } }; - Ok(json!({ + let mut report = json!({ "ok": ok, "sidecar_health": if sidecar_running { "running" + } else if matches!(agent, CodingAgent::Codex) { + "not_running_mcp_start" } else { "not_running_lazy_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) -> Result { +fn doctor_ok( + agent: CodingAgent, + gateway_url: &str, + plugin_hooks_path: Option<&Path>, +) -> Result { let mut ok = true; ok &= print_check( "plugin binary", @@ -230,6 +357,11 @@ fn doctor_ok(agent: CodingAgent, gateway_url: &str) -> Result { ); if healthz(gateway_url) { print_info("sidecar health", "running"); + } else if matches!(agent, CodingAgent::Codex) { + print_info( + "sidecar health", + "not running; the required plugin MCP starts it before the captured turn", + ); } else { print_info( "sidecar health", @@ -244,11 +376,21 @@ fn doctor_ok(agent: CodingAgent, gateway_url: &str) -> Result { ); } CodingAgent::Codex => { - ok &= print_check( - "codex provider alias", - codex_provider_installed(gateway_url), - ); - ok &= print_check("codex hooks", codex_hooks_installed(gateway_url)?); + 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(plugin_hooks_path)?; + ok &= print_check("codex provider alias", provider); + ok &= print_check("codex hooks", hooks); + let trust = if hooks { + codex_hook_trust_report(plugin_hooks_path)? + } 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!( @@ -260,13 +402,24 @@ fn doctor_ok(agent: CodingAgent, gateway_url: &str) -> Result { Ok(ok) } +fn plugin_hooks_path_from_env() -> Result { + env::var_os("PLUGIN_ROOT") + .map(PathBuf::from) + .map(|root| root.join("hooks").join("hooks.json")) + .ok_or_else(|| "PLUGIN_ROOT is required for Codex plugin hook setup".into()) +} + #[cfg(test)] use crate::installer::generated_hooks; #[cfg(test)] +use crate::sidecar::*; +#[cfg(test)] use claude::*; #[cfg(test)] use codex::*; #[cfg(test)] +use codex_app_server::*; +#[cfg(test)] use shared::*; #[cfg(test)] diff --git a/crates/cli/src/plugin_shim/shared.rs b/crates/cli/src/plugin_shim/shared.rs index 70cae8d49..8462f718d 100644 --- a/crates/cli/src/plugin_shim/shared.rs +++ b/crates/cli/src/plugin_shim/shared.rs @@ -1,77 +1,25 @@ // 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. +//! Filesystem, hook transport, and process helpers shared by plugin shims. use std::env; -use std::fs::{self, OpenOptions}; +use std::fs; 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::process::{Command, ExitCode}; use std::time::Duration; -use reqwest::Url; use serde_json::{Value, json}; use toml_edit::{DocumentMut, Item, Table}; use crate::config::CodingAgent; +pub(super) use crate::file_io::atomic_write; +use crate::sidecar::{DEFAULT_URL, loopback_authority, parse_loopback_url}; +pub(super) use crate::sidecar::{current_exe, healthz, plugin_idle_timeout, relay_binary}; -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) const MAX_HOOK_RESPONSE_BYTES: usize = 1024 * 1024; 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() { @@ -101,86 +49,6 @@ pub(super) fn write_json(path: &Path, value: &Value) -> Result<(), String> { 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() { @@ -270,137 +138,110 @@ pub(super) fn restore_file_snapshot(snapshot: &FileSnapshot) -> Result<(), Strin } } -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) +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) struct HookForwardError { + message: String, + retryable: bool, } -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}" - )); +impl HookForwardError { + pub(super) fn retryable(message: impl Into) -> Self { + Self { + message: message.into(), + retryable: true, } - Ok(None) => {} - Err(error) => { - let _ = fs::remove_file(pid_path); - return Err(format!( - "failed to inspect nemo-relay sidecar process: {error}" - )); + } + + pub(super) fn not_retryable(message: impl Into) -> Self { + Self { + message: message.into(), + retryable: false, } } - 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}" - )); + + pub(super) fn is_retryable(&self) -> bool { + self.retryable + } +} + +impl std::fmt::Display for HookForwardError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(&self.message) } - 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> { +pub(super) fn post_hook( + agent: CodingAgent, + url: &str, + payload: &[u8], +) -> Result, HookForwardError> { let hook_path = match agent { CodingAgent::ClaudeCode => "/hooks/claude-code", CodingAgent::Codex => "/hooks/codex", _ => { - return Err(format!( + return Err(HookForwardError::not_retryable(format!( "plugin shim hook forwarding supports claude and codex, got {}", agent.as_arg() - )); + ))); } }; - let (host, port) = parse_loopback_url(url)?; + let (host, port) = parse_loopback_url(url).map_err(HookForwardError::not_retryable)?; let addrs = (host.as_str(), port) .to_socket_addrs() - .map_err(|error| format!("hook forward failed: {error}"))?; + .map_err(|error| HookForwardError::retryable(format!("hook forward failed: {error}")))?; let mut stream = None; + let mut connect_error = None; for addr in addrs { match TcpStream::connect_timeout(&addr, Duration::from_secs(2)) { Ok(candidate) => { stream = Some(candidate); break; } - Err(_) => continue, + Err(error) => connect_error = Some(error), } } let Some(mut stream) = stream else { - return Err("hook forward failed: connection timed out".into()); + let detail = connect_error + .map(|error| error.to_string()) + .unwrap_or_else(|| "no loopback address resolved".into()); + return Err(HookForwardError::retryable(format!( + "hook forward failed before sending request bytes: {detail}" + ))); }; stream .set_read_timeout(Some(Duration::from_secs(2))) - .map_err(|error| format!("failed to set read timeout: {error}"))?; + .map_err(|error| { + HookForwardError::not_retryable(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}"))?; + .map_err(|error| { + HookForwardError::not_retryable(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", + "POST {hook_path} HTTP/1.1\r\nHost: {}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + loopback_authority(&host, port), payload.len() ); stream .write_all(request.as_bytes()) .and_then(|_| stream.write_all(payload)) - .map_err(|error| format!("hook forward failed: {error}"))?; + .map_err(|error| { + HookForwardError::not_retryable(format!("hook forward failed: {error}")) + })?; let mut response = Vec::new(); stream + .take(MAX_HOOK_RESPONSE_BYTES.saturating_add(1) as u64) .read_to_end(&mut response) - .map_err(|error| format!("hook forward failed: {error}"))?; - parse_http_response(&response) + .map_err(|error| { + HookForwardError::not_retryable(format!("hook forward failed: {error}")) + })?; + if response.len() > MAX_HOOK_RESPONSE_BYTES { + return Err(HookForwardError::not_retryable(format!( + "hook forward response exceeds the {MAX_HOOK_RESPONSE_BYTES}-byte limit" + ))); + } + parse_http_response(&response).map_err(HookForwardError::not_retryable) } pub(super) fn parse_http_response(response: &[u8]) -> Result, String> { @@ -428,62 +269,6 @@ pub(super) fn parse_http_response(response: &[u8]) -> Result, String> { } } -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(); @@ -496,96 +281,46 @@ pub(super) fn gateway_url(agent: CodingAgent, explicit: Option<&str>) -> String 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) -} +#[cfg(windows)] +pub(crate) fn portable_executable_path(path: PathBuf) -> PathBuf { + use std::ffi::OsString; + use std::os::windows::ffi::{OsStrExt, OsStringExt}; -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) + 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) } -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() +#[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 VERBATIM_PREFIX: &[u16] = &[b'\\' as u16, b'\\' as u16, b'?' as u16, b'\\' as u16]; + const VERBATIM_UNC_PREFIX: &[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(VERBATIM_UNC_PREFIX) { + let mut normalized = vec![b'\\' as u16, b'\\' as u16]; + normalized.extend_from_slice(rest); + Some(normalized) } else { - sanitized + encoded.strip_prefix(VERBATIM_PREFIX).map(ToOwned::to_owned) } } -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") } diff --git a/crates/cli/src/plugins/config_io.rs b/crates/cli/src/plugins/config_io.rs index d3c55fc3e..2f07c89b6 100644 --- a/crates/cli/src/plugins/config_io.rs +++ b/crates/cli/src/plugins/config_io.rs @@ -6,7 +6,6 @@ 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; @@ -424,7 +423,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::config::load_bounded_dynamic_plugin_manifest(manifest_ref) .map(|(manifest, _)| manifest.plugin.id.trim() == plugin_id) .unwrap_or(false) }); diff --git a/crates/cli/src/plugins/dynamic_editor.rs b/crates/cli/src/plugins/dynamic_editor.rs index 99111f7ab..8a269fd25 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::config::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 index 8ee4d7b2c..fb554f69e 100644 --- a/crates/cli/src/plugins/lifecycle.rs +++ b/crates/cli/src/plugins/lifecycle.rs @@ -1,23 +1,27 @@ // SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -use std::collections::{BTreeSet, HashMap}; +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, - DynamicPluginRecord, DynamicPluginValidationStatus, + DynamicPluginManifestLoad, DynamicPluginRecord, DynamicPluginValidationStatus, WorkerRuntime, }; use serde_json::{Map, Value}; +use sha2::{Digest, Sha256}; use crate::config::{ - PluginsAddCommand, PluginsDisableCommand, PluginsEnableCommand, PluginsInspectCommand, - PluginsListCommand, PluginsRemoveCommand, PluginsValidateCommand, ResolvedConfig, - ResolvedDynamicPluginConfig, ServerArgs, resolve_plugins_config, + MAX_BOOTSTRAP_IDENTITY_FILE_BYTES, PluginsAddCommand, PluginsDisableCommand, + PluginsEnableCommand, PluginsInspectCommand, PluginsListCommand, PluginsRemoveCommand, + PluginsValidateCommand, ResolvedConfig, ResolvedDynamicPluginConfig, ServerArgs, + load_bounded_dynamic_plugin_manifest_bytes, read_bounded_regular_file, resolve_plugins_config, }; use crate::error::{CliError, PluginLifecycleFailureKind}; use crate::plugins::policy::{ @@ -36,8 +40,10 @@ mod target; mod trust; use self::environment::{ - ProcessPythonEnvironmentCommandRunner, PythonEnvironmentCommandRunner, environment_state, - provision_python_environment, remove_managed_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::responses::{ ValidateResponseInput, failure, generic_failure, inspect_data, inspect_success, list_success, @@ -52,6 +58,24 @@ 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: PluginsAddCommand, server: &ServerArgs) -> Result<(), CliError> { add_with_environment_runner(command, server, &ProcessPythonEnvironmentCommandRunner) } @@ -443,46 +467,1275 @@ pub(crate) fn remove(command: PluginsRemoveCommand, server: &ServerArgs) -> Resu 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(entry) = find_record_by_id(&scopes, &resolved_plugin.plugin_id)? else { + 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 entry.record.is_tombstoned() || !entry.record.spec.enabled { + if record.is_tombstoned() || !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 - )) - })?; + 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: 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(), + 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, }); } @@ -814,7 +2067,7 @@ fn load_manifest_for_action( path: impl Into, ) -> Result<(DynamicPluginManifest, String), CliError> { let path = path.into(); - DynamicPluginManifest::load_from_path(&path) + crate::config::load_bounded_dynamic_plugin_manifest(&path) .map_err(|error| CliError::Config(format!("dynamic plugin {action} failed: {error}"))) } @@ -958,7 +2211,7 @@ fn required_startup_failure( )); } - if let Err(error) = DynamicPluginManifest::load_from_path(&manifest_ref) { + if let Err(error) = crate::config::load_bounded_dynamic_plugin_manifest(&manifest_ref) { return Some(format!( "- {}: required dynamic plugin manifest at {} is unreadable: {}", entry.record.metadata.id, diff --git a/crates/cli/src/plugins/lifecycle/environment.rs b/crates/cli/src/plugins/lifecycle/environment.rs index c66eacf0f..10fee5167 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,105 @@ 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() + || 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 +166,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 +248,239 @@ 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::config::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::config::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() { + 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::config::read_bounded_regular_file(&source, "managed Python environment file")?; + *total = total.saturating_add(bytes.len() as u64); + if *total > crate::config::MAX_BOOTSTRAP_IDENTITY_FILE_BYTES { + return Err(format!( + "managed Python environment exceeds the {}-byte attestation budget", + crate::config::MAX_BOOTSTRAP_IDENTITY_FILE_BYTES + )); + } + digest.update(relative.to_string_lossy().as_bytes()); + digest.update([0]); + digest.update(&bytes); + } + ancestors.pop(); + Ok(()) +} + pub(super) fn remove_managed_environment( state_path: &Path, plugin_id: &str, @@ -174,6 +520,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/trust.rs b/crates/cli/src/plugins/lifecycle/trust.rs index b3c6b225f..82a2de462 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; @@ -319,10 +318,11 @@ 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(), - })?; + crate::config::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 +348,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::config::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 +402,11 @@ 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::config::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/server.rs b/crates/cli/src/server.rs index 386ab8a26..5161ec470 100644 --- a/crates/cli/src/server.rs +++ b/crates/cli/src/server.rs @@ -3,13 +3,15 @@ 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::extract::rejection::JsonRejection; use axum::extract::{DefaultBodyLimit, State}; -use axum::http::HeaderMap; +use axum::http::{HeaderMap, HeaderValue, StatusCode}; +use axum::response::{IntoResponse, Response}; use axum::routing::{get, post}; use axum::{Json, Router}; use nemo_relay::plugin::dynamic::{ @@ -23,14 +25,15 @@ use nemo_relay_adaptive::plugin_component::register_adaptive_component; use nemo_relay_pii_redaction::component::register_pii_redaction_component; use reqwest::Client; use serde_json::Value; +use subtle::ConstantTimeEq; use tokio::net::TcpListener; use tokio::sync::oneshot; use crate::adapters::{claude_code, codex, hermes}; -use crate::config::GatewayConfig; +use crate::config::{BootstrapChallengeKey, GatewayConfig, ManagedBootstrapIdentity}; use crate::error::CliError; use crate::gateway; -use crate::plugins::lifecycle::ActiveDynamicPluginComponent; +use crate::plugins::lifecycle::{ActiveDynamicPluginComponent, DynamicPluginActivationSnapshot}; use crate::session::SessionManager; const HTTP_CONNECT_TIMEOUT: Duration = Duration::from_secs(30); @@ -40,17 +43,47 @@ 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) http: Client, pub(crate) sessions: SessionManager, pub(crate) last_activity: Arc>, + pub(crate) bootstrap_shutdown: Option, +} + +#[derive(Clone)] +pub(crate) struct BootstrapShutdown { + token: String, + sender: Arc>>>, } /// 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>, ) -> Result<(), CliError> { - let listener = TcpListener::bind(config.bind).await.map_err(|err| { + 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, + bootstrap_fingerprint, + managed_bootstrap, + Some(ShutdownMode::ProcessSignal), + ready_file, + ) + .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. @@ -62,23 +95,15 @@ pub(crate) async fn serve_with_dynamic( `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 + 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) { +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)); @@ -122,6 +147,25 @@ pub(crate) async fn serve_listener( 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(), + Some(bootstrap_fingerprint), + None, + shutdown.map(ShutdownMode::Receiver), + None, + ) + .await +} + /// Serves the gateway router and activates enabled dynamic plugin components. pub(crate) async fn serve_listener_with_dynamic( listener: TcpListener, @@ -133,7 +177,10 @@ pub(crate) async fn serve_listener_with_dynamic( listener, config, dynamic_plugins, + None, + None, shutdown.map(ShutdownMode::Receiver), + None, ) .await } @@ -149,18 +196,41 @@ async fn serve_listener_with_dynamic_inner( listener: TcpListener, config: GatewayConfig, dynamic_plugins: Vec, + bootstrap_fingerprint: Option, + managed_bootstrap: Option, shutdown_mode: Option, + ready_file: Option<&Path>, ) -> Result<(), CliError> { + let bootstrap_challenge_key = bootstrap_fingerprint + .as_ref() + .map(|_| BootstrapChallengeKey::load()) + .transpose()?; let plugin_activation = PluginActivation::initialize(config.plugin_config.clone(), dynamic_plugins).await?; - let state = AppState::new(config); + let (bootstrap_shutdown, bootstrap_shutdown_rx) = bootstrap_shutdown_channel(); + let state = AppState::new_with_bootstrap( + config, + bootstrap_fingerprint, + bootstrap_challenge_key, + bootstrap_shutdown, + ); 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 local_address = listener.local_addr()?; + if let Some(identity) = managed_bootstrap.as_ref() { + identity.verify_current()?; + } + crate::sidecar::publish_sidecar_owner_from_env(local_address).map_err(CliError::Launch)?; + if let Some(path) = ready_file { + write_ready_file(path, local_address)?; + } + 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; @@ -177,6 +247,18 @@ async fn serve_listener_with_dynamic_inner( })), 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) @@ -243,7 +325,17 @@ pub(crate) fn router(config: GatewayConfig) -> Router { } impl AppState { + #[cfg(test)] fn new(config: GatewayConfig) -> Self { + Self::new_with_bootstrap(config, None, None, None) + } + + fn new_with_bootstrap( + config: GatewayConfig, + bootstrap_fingerprint: Option, + bootstrap_challenge_key: Option, + bootstrap_shutdown: Option, + ) -> Self { let sessions = SessionManager::new(config.clone()); sessions.start_idle_sweeper(); let http = Client::builder() @@ -254,9 +346,12 @@ impl AppState { .expect("gateway HTTP client configuration is valid"); Self { config, + bootstrap_fingerprint, + bootstrap_challenge_key, http, sessions, last_activity: Arc::new(Mutex::new(Instant::now())), + bootstrap_shutdown, } } @@ -271,6 +366,7 @@ 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/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)) @@ -286,15 +382,145 @@ fn router_with_state(state: AppState) -> Router { .with_state(state) } -async fn healthz(State(state): State) -> Json { - state.touch(); - Json(serde_json::json!({ "status": "ok" })) +fn bootstrap_shutdown_channel() -> (Option, Option>) { + let Some(token) = std::env::var("NEMO_RELAY_BOOTSTRAP_SHUTDOWN_TOKEN") + .ok() + .filter(|token| !token.is_empty()) + else { + return (None, None); + }; + let (sender, receiver) = oneshot::channel(); + ( + Some(BootstrapShutdown { + token, + sender: Arc::new(Mutex::new(Some(sender))), + }), + Some(receiver), + ) } -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 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": 1 + })), + ) + .into_response() +} + +fn write_ready_file(path: &Path, bind: SocketAddr) -> Result<(), CliError> { + let bytes = serde_json::to_vec(&serde_json::json!({ + "address": bind, + "service": "nemo-relay", + "version": env!("CARGO_PKG_VERSION"), + "bootstrap_protocol": 1 + })) + .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( @@ -307,20 +533,38 @@ async fn idle_shutdown_future( .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 { + 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 + }) +} + struct PluginActivation { active: bool, native: Option, worker: Option, + _snapshots: Vec>, } impl PluginActivation { @@ -333,6 +577,7 @@ impl PluginActivation { active: false, native: None, worker: None, + _snapshots: Vec::new(), }); }; register_adaptive_component().map_err(|error| { @@ -341,16 +586,26 @@ impl PluginActivation { register_pii_redaction_component().map_err(|error| { CliError::Config(format!("PII redaction plugin registration failed: {error}")) })?; + 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.manifest_ref.clone().ok_or_else(|| { - CliError::Config(format!( - "native dynamic plugin '{}' has no manifest_ref in lifecycle state", - plugin.plugin_id - )) - })?; + 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, @@ -361,20 +616,34 @@ impl PluginActivation { .iter() .filter(|plugin| plugin.kind == DynamicPluginKind::Worker) .map(|plugin| { - let manifest_ref = plugin.manifest_ref.clone().ok_or_else(|| { - CliError::Config(format!( - "worker dynamic plugin '{}' has no manifest_ref in lifecycle state", - plugin.plugin_id - )) - })?; + 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.environment_ref.clone(), + 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 @@ -383,6 +652,11 @@ impl PluginActivation { 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 @@ -415,6 +689,7 @@ impl PluginActivation { active: true, native, worker, + _snapshots: snapshots, }) } diff --git a/crates/cli/src/session.rs b/crates/cli/src/session.rs index 839b470e0..ccccb5eb2 100644 --- a/crates/cli/src/session.rs +++ b/crates/cli/src/session.rs @@ -1222,15 +1222,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()); } @@ -1239,7 +1246,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) } @@ -1346,7 +1353,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(()); }; @@ -1355,6 +1366,7 @@ impl Session { PopScopeParams::builder() .handle_uuid(&scope.uuid) .output(output) + .metadata_opt(boundary_metadata) .build(), )?; Ok(()) diff --git a/crates/cli/src/sidecar.rs b/crates/cli/src/sidecar.rs new file mode 100644 index 000000000..8e953117a --- /dev/null +++ b/crates/cli/src/sidecar.rs @@ -0,0 +1,788 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Shared native gateway sidecar lifecycle. + +mod health; +mod process; +mod state; + +use std::env; +use std::ffi::OsString; +use std::fs::{self, OpenOptions}; +use std::net::SocketAddr; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, Stdio}; +use std::sync::{OnceLock, mpsc}; +use std::thread; +use std::time::{Duration, Instant}; + +use crate::config::{CodingAgent, ServerArgs, resolve_persistent_server_config}; +use crate::error::CliError; +use crate::file_io::{LockAttempt, try_lock_exclusive}; +#[cfg(test)] +pub(crate) use health::healthz_compatible; +use health::{ + RelayHealth, probe as probe_relay_health, probe_after_lock as probe_relay_health_after_lock, +}; +pub(crate) use health::{healthz, loopback_authority, loopback_bind, parse_loopback_url}; +use process::DetachedSidecarProcess; +#[cfg(all(windows, not(test)))] +use process::SidecarJob; +#[cfg(all(test, windows))] +pub(crate) use process::SidecarJob; +pub(crate) use process::configure_detached_sidecar; +pub(crate) use process::join_sidecar_job_from_env; +#[cfg(all(test, unix))] +pub(crate) use process::terminate_sidecar_process_tree; +#[cfg(test)] +pub(crate) use process::{ + WINDOWS_CREATE_BREAKAWAY_FROM_JOB, WINDOWS_CREATE_NEW_PROCESS_GROUP, WINDOWS_CREATE_NO_WINDOW, + WINDOWS_JOB_OBJECT_LIMIT_BREAKAWAY_OK, WINDOWS_JOB_OBJECT_LIMIT_KILL_ON_CLOSE, + WINDOWS_JOB_OBJECT_LIMIT_SILENT_BREAKAWAY_OK, terminate_unready_sidecar, + windows_sidecar_creation_flags, +}; +pub(crate) use state::{BOOTSTRAP_AGENT_ENV, BOOTSTRAP_STATE_DIR_ENV}; +use state::{ + create_private_runtime_dir, open_lock as open_sidecar_lock, read_owner_record, + read_ready_file as read_sidecar_ready_file, runtime_dir, +}; +pub(crate) use state::{ + lock_endpoint as lock_sidecar_endpoint, lock_path as sidecar_lock_path, + owner_path as sidecar_owner_path, owner_paths as sidecar_owner_paths, + pid_path as sidecar_pid_path, state_dir as sidecar_state_dir, + validate_owner as validate_sidecar_owner, +}; +#[cfg(test)] +pub(crate) use state::{ + lock_endpoint_for as lock_sidecar_endpoint_for, lock_name as sidecar_lock_name, + runtime_dir_for, stop_owned_record as stop_owned_sidecar_record, + write_owner as write_sidecar_owner, +}; +pub(crate) use state::{ + publish_owner_from_env as publish_sidecar_owner_from_env, stop_owned as stop_owned_sidecar, +}; + +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(super) const SIDECAR_LOCK_TIMEOUT: Duration = Duration::from_secs(20); +const SIDECAR_START_TIMEOUT: Duration = Duration::from_secs(10); +pub(crate) const BOOTSTRAP_PROTOCOL_VERSION: u64 = 1; +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct GatewayEndpoint { + pub(crate) address: SocketAddr, + pub(crate) url: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct GatewayBootstrap { + pub(crate) endpoint: GatewayEndpoint, + pub(crate) started: bool, +} + +/// Complete launch and compatibility contract for one shared gateway. +/// +/// Callers construct this once and use the same value for discovery, startup, health checks, and +/// recovery. Keeping those inputs together prevents a gateway from being started with one +/// identity and later adopted under another. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct GatewaySpec { + agent: CodingAgent, + bind: SocketAddr, + sidecar_args: Vec, + bootstrap_fingerprint: Option, +} + +impl GatewaySpec { + pub(crate) fn new(agent: CodingAgent, bind: SocketAddr) -> Self { + Self { + agent, + bind, + sidecar_args: Vec::new(), + bootstrap_fingerprint: None, + } + } + + pub(crate) fn with_launch_args(mut self, args: Vec) -> Self { + self.sidecar_args = args; + self + } + + pub(crate) fn with_fingerprint(mut self, fingerprint: impl Into) -> Self { + self.bootstrap_fingerprint = Some(fingerprint.into()); + self + } + + pub(crate) fn bind(&self) -> SocketAddr { + self.bind + } + + pub(crate) fn ensure(&self) -> Result { + ensure_gateway(self) + } + + pub(crate) fn is_healthy(&self, url: &str) -> bool { + probe_relay_health(url, self.bootstrap_fingerprint.as_deref()) == RelayHealth::Compatible + } +} + +/// Persistent Codex gateway settings shared by MCP bootstrap and hook recovery. +pub(crate) struct CodexGatewaySpec { + pub(crate) gateway: GatewaySpec, + pub(crate) max_hook_payload_bytes: usize, +} + +pub(crate) fn resolve_codex_gateway( + server_args: &ServerArgs, + 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 sidecar_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(CodexGatewaySpec { + gateway: GatewaySpec::new(CodingAgent::Codex, bind) + .with_launch_args(sidecar_args) + .with_fingerprint(bootstrap_fingerprint), + max_hook_payload_bytes, + }) +} + +#[cfg(test)] +pub(crate) fn ensure_sidecar_bind( + agent: CodingAgent, + bind: SocketAddr, +) -> Result { + GatewaySpec::new(agent, bind) + .ensure() + .map(|bootstrap| bootstrap.endpoint) +} + +fn ensure_gateway(spec: &GatewaySpec) -> Result { + let agent = spec.agent; + let bind = spec.bind; + let sidecar_args = &spec.sidecar_args; + let bootstrap_fingerprint = spec.bootstrap_fingerprint.as_deref(); + if !bind.ip().is_loopback() { + return Err(format!( + "plugin sidecars require a loopback bind address, got {bind}" + )); + } + let url = format!("http://{bind}"); + let runtime = runtime_dir(); + create_private_runtime_dir(&runtime).map_err(|error| { + sidecar_start_error( + agent, + &url, + &runtime, + &format!("failed to create {}: {error}", runtime.display()), + ) + })?; + let state = sidecar_state_dir()?; + create_private_runtime_dir(&state).map_err(|error| { + sidecar_start_error( + agent, + &url, + &runtime, + &format!("failed to create {}: {error}", state.display()), + ) + })?; + if bind.port() == 0 { + return start_sidecar_bind( + agent, + bind, + &runtime, + &state, + sidecar_args, + bootstrap_fingerprint, + None, + ) + .map_err(|error| sidecar_start_error(agent, &url, &runtime, &error)); + } + let lock_path = sidecar_lock_path(&state, &url); + let initial_health = probe_relay_health(&url, bootstrap_fingerprint); + match initial_health { + RelayHealth::Compatible => { + return Ok(GatewayBootstrap { + endpoint: GatewayEndpoint { address: bind, url }, + started: false, + }); + } + RelayHealth::Incompatible | RelayHealth::Foreign | RelayHealth::Unavailable => {} + } + let lock = OpenOptions::new() + .create(true) + .truncate(false) + .read(true) + .write(true) + .open(&lock_path) + .map_err(|error| { + sidecar_start_error( + agent, + &url, + &runtime, + &format!( + "failed to open sidecar lock {}: {error}", + lock_path.display() + ), + ) + })?; + let lock_deadline = Instant::now() + SIDECAR_LOCK_TIMEOUT; + loop { + match try_lock_exclusive(&lock) { + Ok(LockAttempt::Acquired) => break, + Ok(LockAttempt::Contended) => { + if probe_relay_health(&url, bootstrap_fingerprint) == RelayHealth::Compatible { + return Ok(GatewayBootstrap { + endpoint: GatewayEndpoint { address: bind, url }, + started: false, + }); + } + if Instant::now() >= lock_deadline { + return Err(sidecar_start_error( + agent, + &url, + &runtime, + "sidecar lock timed out", + )); + } + thread::sleep(Duration::from_millis(50)); + } + Err(error) => { + return Err(sidecar_start_error( + agent, + &url, + &runtime, + &format!("failed to acquire sidecar lock: {error}"), + )); + } + } + } + match probe_relay_health_after_lock(&url, bootstrap_fingerprint) { + RelayHealth::Compatible => { + return Ok(GatewayBootstrap { + endpoint: GatewayEndpoint { address: bind, url }, + started: false, + }); + } + RelayHealth::Incompatible => return Err(incompatible_relay_error(&url)), + RelayHealth::Foreign => return Err(foreign_listener_error(&url)), + RelayHealth::Unavailable => {} + } + let result = start_sidecar_bind( + agent, + bind, + &runtime, + &state, + sidecar_args, + bootstrap_fingerprint, + Some(lock), + ); + result.map_err(|error| sidecar_start_error(agent, &url, &runtime, &error)) +} + +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 its idle shutdown, or run `nemo-relay install codex --force` before retrying" + ) +} + +fn sidecar_start_error(agent: CodingAgent, url: &str, runtime: &Path, error: &str) -> String { + let log_path = runtime.join(format!("{}-sidecar.log", agent.as_arg())); + let manual = parse_loopback_url(url) + .map(|(host, port)| format!("nemo-relay --bind {}", loopback_authority(&host, port))) + .unwrap_or_else(|_| "nemo-relay --bind 127.0.0.1:47632".into()); + format!( + "{error}; inspect {}; or start the gateway manually with `{manual}`", + log_path.display() + ) +} + +#[cfg(all(test, unix))] +pub(super) fn start_sidecar(agent: CodingAgent, url: &str, runtime: &Path) -> Result<(), String> { + start_sidecar_bind( + agent, + loopback_bind(url)?, + runtime, + runtime, + &[], + None, + None, + ) + .map(|_| ()) +} + +struct ArmedSidecarChild { + process: Option, + startup_pid_path: PathBuf, + state: PathBuf, + agent: CodingAgent, + pid: u32, +} + +impl ArmedSidecarChild { + fn new( + child: Child, + startup_pid_path: PathBuf, + state: &Path, + agent: CodingAgent, + #[cfg(windows)] prepared_job: Option, + ) -> Self { + let pid = child.id(); + Self { + process: Some(DetachedSidecarProcess::new( + child, + #[cfg(windows)] + prepared_job, + )), + startup_pid_path, + state: state.to_path_buf(), + agent, + pid, + } + } + + fn id(&self) -> u32 { + self.pid + } + + fn try_wait(&mut self) -> std::io::Result> { + self.process + .as_mut() + .expect("armed sidecar process is present") + .try_wait() + } + + fn disarm(mut self) -> DetachedSidecarProcess { + self.process + .take() + .expect("armed sidecar process is present") + } +} + +impl Drop for ArmedSidecarChild { + fn drop(&mut self) { + if let Some(mut process) = self.process.take() { + // The launcher may exit before readiness while leaving descendants behind. Always + // target the detached process group even when the direct child has already exited. + process.terminate(); + cleanup_sidecar_records_for_pid(&self.state, self.agent, self.pid); + } + let _ = fs::remove_file(&self.startup_pid_path); + } +} + +fn cleanup_sidecar_records_for_pid(runtime: &Path, agent: CodingAgent, pid: u32) { + let Ok(paths) = sidecar_owner_paths(runtime, agent) else { + return; + }; + for owner_path in paths { + let Ok(Some(owner)) = read_owner_record(&owner_path) else { + continue; + }; + if owner.pid != pid { + continue; + } + let _ = fs::remove_file(&owner_path); + let _ = fs::remove_file(sidecar_pid_path(runtime, agent, &owner.url)); + } +} + +fn start_sidecar_bind( + agent: CodingAgent, + bind: SocketAddr, + runtime: &Path, + state: &Path, + sidecar_args: &[OsString], + bootstrap_fingerprint: Option<&str>, + mut startup_lock: Option, +) -> Result { + let requested_url = format!("http://{bind}"); + if bind.port() != 0 { + match probe_relay_health(&requested_url, bootstrap_fingerprint) { + RelayHealth::Compatible => { + return Ok(GatewayBootstrap { + endpoint: GatewayEndpoint { + address: bind, + url: requested_url, + }, + started: false, + }); + } + RelayHealth::Incompatible => { + return Err(incompatible_relay_error(&requested_url)); + } + RelayHealth::Foreign => return Err(foreign_listener_error(&requested_url)), + RelayHealth::Unavailable => {} + } + } + let relay = relay_binary()?; + let log_path = runtime.join(format!("{}-sidecar.log", agent.as_arg())); + let ready_path = runtime.join(format!( + "{}-sidecar-{}-{}.ready.json", + agent.as_arg(), + std::process::id(), + uuid::Uuid::now_v7() + )); + let _ = fs::remove_file(&ready_path); + 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 idle_timeout = plugin_idle_timeout()?; + let shutdown_token = uuid::Uuid::now_v7().to_string(); + #[cfg(windows)] + let sidecar_job = SidecarJob::create()?; + let mut command = Command::new(relay); + command + .arg("--bind") + .arg(bind.to_string()) + .arg("--ready-file") + .arg(&ready_path) + .args(sidecar_args) + .env( + "NEMO_RELAY_PLUGIN_IDLE_TIMEOUT_SECS", + idle_timeout.as_secs().to_string(), + ) + .env(BOOTSTRAP_AGENT_ENV, agent.as_arg()) + .env( + crate::config::BOOTSTRAP_FINGERPRINT_ENV, + bootstrap_fingerprint.unwrap_or_default(), + ) + .env(BOOTSTRAP_STATE_DIR_ENV, state) + .env("NEMO_RELAY_BOOTSTRAP_SHUTDOWN_TOKEN", &shutdown_token) + .stdin(Stdio::null()) + .stdout(Stdio::from(log)) + .stderr(Stdio::from(err_log)); + #[cfg(windows)] + sidecar_job.configure_child(&mut command); + if matches!(agent, CodingAgent::Codex) { + command.env("NEMO_RELAY_CONFIG_SCOPE", "user"); + if let Some(config_dir) = crate::config::user_config_dir() { + fs::create_dir_all(&config_dir).map_err(|error| { + format!( + "failed to create Codex sidecar working directory {}: {error}", + config_dir.display() + ) + })?; + command.current_dir(config_dir); + } + } + configure_detached_sidecar(&mut command); + let child = command + .spawn() + .map_err(|error| format!("failed to spawn nemo-relay sidecar: {error}"))?; + let startup_pid_path = ready_path.with_extension("pid"); + let _ = fs::write(&startup_pid_path, child.id().to_string()); + let mut child = ArmedSidecarChild::new( + child, + startup_pid_path, + state, + agent, + #[cfg(windows)] + Some(sidecar_job), + ); + let deadline = Instant::now() + SIDECAR_START_TIMEOUT; + while Instant::now() < deadline { + match read_sidecar_ready_file(&ready_path) { + Ok(Some(endpoint)) + if (bind.port() == 0 || endpoint.address == bind) + && probe_relay_health(&endpoint.url, bootstrap_fingerprint) + == RelayHealth::Compatible => + { + let ownership_lock = match startup_lock.take() { + Some(lock) => lock, + None => lock_sidecar_endpoint(state, &endpoint.url)?, + }; + let owner_path = sidecar_owner_path(state, agent, &endpoint.url); + let pid_path = sidecar_pid_path(state, agent, &endpoint.url); + let pid = child.id(); + validate_sidecar_owner( + &owner_path, + &pid_path, + pid, + &endpoint.url, + &shutdown_token, + bootstrap_fingerprint, + )?; + if let Err(error) = handoff_detached_sidecar_to_reaper( + child.disarm(), + owner_path.clone(), + pid_path.clone(), + sidecar_lock_path(state, &endpoint.url), + ) { + let _ = fs::remove_file(&owner_path); + let _ = fs::remove_file(&pid_path); + let _ = fs::remove_file(&ready_path); + return Err(error); + } + drop(ownership_lock); + let _ = fs::remove_file(&ready_path); + return Ok(GatewayBootstrap { + endpoint, + started: true, + }); + } + Ok(_) => {} + Err(error) => { + let _ = fs::remove_file(&ready_path); + return Err(error); + } + } + match child.try_wait() { + Ok(Some(status)) => { + let _ = fs::remove_file(&ready_path); + if bind.port() != 0 + && probe_relay_health(&requested_url, bootstrap_fingerprint) + == RelayHealth::Compatible + { + return Ok(GatewayBootstrap { + endpoint: GatewayEndpoint { + address: bind, + url: requested_url, + }, + started: false, + }); + } + return Err(format!( + "nemo-relay sidecar exited before becoming ready at {requested_url}: {status}" + )); + } + Ok(None) => {} + Err(error) => { + let _ = fs::remove_file(&ready_path); + return Err(format!( + "failed to inspect nemo-relay sidecar process: {error}" + )); + } + } + thread::sleep(Duration::from_millis(50)); + } + let _ = fs::remove_file(&ready_path); + Err(format!( + "nemo-relay sidecar did not become ready at {requested_url}; terminated startup process" + )) +} + +struct SidecarReapRequest { + process: DetachedSidecarProcess, + exited: bool, + owner_path: PathBuf, + pid_path: PathBuf, + lock_path: PathBuf, +} + +static SIDECAR_REAPER: OnceLock, String>> = OnceLock::new(); + +fn sidecar_reaper_sender() -> Result<&'static mpsc::Sender, String> { + match SIDECAR_REAPER.get_or_init(|| { + let (sender, receiver) = mpsc::channel(); + thread::Builder::new() + .name("nemo-relay-sidecar-reaper".into()) + .spawn(move || run_sidecar_reaper(receiver)) + .map(|_| sender) + .map_err(|error| format!("failed to start nemo-relay sidecar reaper: {error}")) + }) { + Ok(sender) => Ok(sender), + Err(error) => Err(error.clone()), + } +} + +#[cfg(test)] +pub(super) fn handoff_sidecar_to_reaper( + child: Child, + owner_path: PathBuf, + pid_path: PathBuf, + lock_path: PathBuf, +) -> Result<(), String> { + let process = DetachedSidecarProcess::new( + child, + #[cfg(windows)] + None, + ); + handoff_detached_sidecar_to_reaper(process, owner_path, pid_path, lock_path) +} + +fn handoff_detached_sidecar_to_reaper( + process: DetachedSidecarProcess, + owner_path: PathBuf, + pid_path: PathBuf, + lock_path: PathBuf, +) -> Result<(), String> { + let sender = match sidecar_reaper_sender() { + Ok(sender) => sender, + Err(error) => { + return terminate_reaper_handoff(process, &pid_path, error); + } + }; + let request = SidecarReapRequest { + process, + exited: false, + owner_path, + pid_path, + lock_path, + }; + match sender.send(request) { + Ok(()) => Ok(()), + Err(error) => terminate_reaper_handoff( + error.0.process, + &error.0.pid_path, + "nemo-relay sidecar reaper stopped unexpectedly".into(), + ), + } +} + +fn terminate_reaper_handoff( + mut process: DetachedSidecarProcess, + pid_path: &Path, + error: String, +) -> Result<(), String> { + process.terminate(); + let _ = fs::remove_file(pid_path); + Err(error) +} + +fn run_sidecar_reaper(receiver: mpsc::Receiver) { + let mut children = Vec::new(); + loop { + match receiver.recv_timeout(Duration::from_millis(100)) { + Ok(request) => children.push(request), + Err(mpsc::RecvTimeoutError::Timeout) => {} + Err(mpsc::RecvTimeoutError::Disconnected) if children.is_empty() => return, + Err(mpsc::RecvTimeoutError::Disconnected) => {} + } + while let Ok(request) = receiver.try_recv() { + children.push(request); + } + let mut index = 0; + while index < children.len() { + if !children[index].exited { + match children[index].process.try_wait() { + Ok(Some(_)) => children[index].exited = true, + Ok(None) => { + index += 1; + continue; + } + Err(error) => { + eprintln!("failed to inspect nemo-relay sidecar process: {error}"); + index += 1; + continue; + } + } + } + children[index].process.terminate_retained_descendants(); + if cleanup_reaped_sidecar(&children[index]) { + let request = children.swap_remove(index); + drop(request); + } else { + index += 1; + } + } + } +} + +fn cleanup_reaped_sidecar(request: &SidecarReapRequest) -> bool { + let lock = match open_sidecar_lock(&request.lock_path) { + Ok(lock) => lock, + Err(error) => { + eprintln!("failed to clean up nemo-relay sidecar ownership: {error}"); + return true; + } + }; + match try_lock_exclusive(&lock) { + Ok(LockAttempt::Acquired) => {} + Ok(LockAttempt::Contended) => return false, + Err(error) => { + eprintln!("failed to lock nemo-relay sidecar ownership for cleanup: {error}"); + return true; + } + } + let pid = request.process.id(); + if read_owner_record(&request.owner_path) + .ok() + .flatten() + .is_some_and(|owner| owner.pid == pid) + { + let _ = fs::remove_file(&request.owner_path); + } + if fs::read_to_string(&request.pid_path) + .ok() + .is_some_and(|value| value.trim() == pid.to_string()) + { + let _ = fs::remove_file(&request.pid_path); + } + true +} + +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(crate) fn plugin_idle_timeout() -> Result { + let raw = env::var(crate::config::PLUGIN_IDLE_TIMEOUT_ENV).unwrap_or_else(|_| "300".into()); + let seconds = raw.parse::().map_err(|error| { + format!( + "{} must be a positive integer: {error}", + crate::config::PLUGIN_IDLE_TIMEOUT_ENV + ) + })?; + if seconds == 0 { + return Err(format!( + "{} must be greater than 0", + crate::config::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/sidecar_tests.rs"] +mod tests; diff --git a/crates/cli/src/sidecar/health.rs b/crates/cli/src/sidecar/health.rs new file mode 100644 index 000000000..31f7ee2ff --- /dev/null +++ b/crates/cli/src/sidecar/health.rs @@ -0,0 +1,247 @@ +// 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::io::{Read, Write}; +use std::net::{Ipv4Addr, SocketAddr, TcpStream, ToSocketAddrs}; +use std::thread; +use std::time::Duration; + +use reqwest::Url; +use ring::rand::{SecureRandom, SystemRandom}; +use serde_json::Value; + +use crate::config::BootstrapChallengeKey; + +use super::{BOOTSTRAP_PROTOCOL_VERSION, HEALTHZ_TIMEOUT}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum RelayHealth { + Compatible, + Incompatible, + Foreign, + Unavailable, +} + +pub(crate) fn healthz(url: &str) -> bool { + probe(url, None) == RelayHealth::Compatible +} + +#[cfg(test)] +pub(crate) fn healthz_compatible(url: &str, bootstrap_fingerprint: &str) -> bool { + probe(url, Some(bootstrap_fingerprint)) == RelayHealth::Compatible +} + +pub(super) fn probe_after_lock(url: &str, bootstrap_fingerprint: Option<&str>) -> RelayHealth { + let mut health = probe(url, bootstrap_fingerprint); + for _ in 1..3 { + if health != RelayHealth::Foreign { + break; + } + thread::sleep(Duration::from_millis(50)); + health = probe(url, bootstrap_fingerprint); + } + health +} + +pub(super) fn probe(url: &str, bootstrap_fingerprint: Option<&str>) -> RelayHealth { + let Ok((host, port)) = parse_loopback_url(url) else { + return RelayHealth::Unavailable; + }; + let Ok(addrs) = (host.as_str(), port).to_socket_addrs() else { + return RelayHealth::Unavailable; + }; + 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 RelayHealth::Unavailable; + }; + if stream.set_read_timeout(Some(HEALTHZ_TIMEOUT)).is_err() + || stream.set_write_timeout(Some(HEALTHZ_TIMEOUT)).is_err() + { + return RelayHealth::Foreign; + } + let challenge = bootstrap_fingerprint.map(|fingerprint| { + let key = BootstrapChallengeKey::load().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, + }; + 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; + } + let mut response = Vec::new(); + if stream.take(16 * 1024).read_to_end(&mut response).is_err() { + return RelayHealth::Foreign; + } + let Some((headers, body)) = split_http_response(&response) else { + return RelayHealth::Foreign; + }; + let Ok(body) = serde_json::from_slice::(body) else { + return RelayHealth::Foreign; + }; + 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; + } + if body.get("version").and_then(Value::as_str) != Some(env!("CARGO_PKG_VERSION")) + || headers.starts_with(b"HTTP/1.1 409") + || headers.starts_with(b"HTTP/1.0 409") + { + return RelayHealth::Incompatible; + } + if (headers.starts_with(b"HTTP/1.1 200") || headers.starts_with(b"HTTP/1.0 200")) + && body.get("status").and_then(Value::as_str) == Some("ok") + { + if let Some((fingerprint, nonce, key)) = challenge { + let Some(proof) = http_header(headers, "x-nemo-relay-bootstrap-proof") else { + return RelayHealth::Foreign; + }; + if !key.verify(fingerprint, &nonce, proof) { + return RelayHealth::Foreign; + } + } + return RelayHealth::Compatible; + } + RelayHealth::Foreign +} + +pub(super) fn request_shutdown(url: &str, token: &str) -> Result<(), String> { + let (host, port) = parse_loopback_url(url)?; + let address = (host.as_str(), port) + .to_socket_addrs() + .map_err(|error| format!("failed to resolve managed sidecar {url}: {error}"))? + .next() + .ok_or_else(|| format!("managed sidecar {url} has no socket address"))?; + let mut stream = TcpStream::connect_timeout(&address, 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 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", + loopback_authority(&host, port) + ); + 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 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 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 plugin shim loopback URL {url}: {error}"))?; + if parsed.scheme() != "http" { + return Err(format!( + "plugin shim 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!( + "plugin shim 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/sidecar_health_tests.rs"] +mod tests; diff --git a/crates/cli/src/sidecar/process.rs b/crates/cli/src/sidecar/process.rs new file mode 100644 index 000000000..0f768a9b8 --- /dev/null +++ b/crates/cli/src/sidecar/process.rs @@ -0,0 +1,381 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Cross-platform process detachment and tree cleanup for sidecars. + +#[cfg(windows)] +use std::env; +use std::process::{Child, Command}; +#[cfg(windows)] +use std::sync::OnceLock; + +#[cfg(windows)] +const SIDECAR_JOB_NAME_ENV: &str = "NEMO_RELAY_SIDECAR_JOB_NAME"; +#[cfg(windows)] +static RETAINED_SIDECAR_JOB: OnceLock = OnceLock::new(); + +pub(super) struct DetachedSidecarProcess { + child: Child, + #[cfg(windows)] + job: Option, +} + +impl DetachedSidecarProcess { + pub(super) fn new(child: Child, #[cfg(windows)] prepared_job: Option) -> Self { + #[cfg(windows)] + let job = prepared_job; + Self { + child, + #[cfg(windows)] + job, + } + } + + pub(super) fn id(&self) -> u32 { + self.child.id() + } + + pub(super) fn try_wait(&mut self) -> std::io::Result> { + self.child.try_wait() + } + + pub(super) fn terminate(&mut self) { + #[cfg(windows)] + if let Some(job) = self.job.as_ref() { + job.terminate(); + } + terminate_sidecar_process_tree(&mut self.child); + } + + pub(super) fn terminate_retained_descendants(&self) { + #[cfg(windows)] + if let Some(job) = self.job.as_ref() { + job.terminate(); + } + } +} + +#[cfg(windows)] +pub(crate) struct SidecarJob { + handle: windows_sys::Win32::Foundation::HANDLE, + name: String, +} + +// SAFETY: Job Object handles can be used from any thread, and this wrapper uniquely owns it. +#[cfg(windows)] +unsafe impl Send for SidecarJob {} +// SAFETY: Windows Job Object operations are thread-safe for a live kernel handle. +#[cfg(windows)] +unsafe impl Sync for SidecarJob {} + +#[cfg(windows)] +impl SidecarJob { + pub(crate) fn create() -> Result { + use std::os::windows::ffi::OsStrExt; + use windows_sys::Win32::Foundation::CloseHandle; + use windows_sys::Win32::System::JobObjects::{ + CreateJobObjectW, JOBOBJECT_EXTENDED_LIMIT_INFORMATION, + JobObjectExtendedLimitInformation, SetInformationJobObject, + }; + + let name = format!("Local\\NeMoRelaySidecar-{}", uuid::Uuid::now_v7().simple()); + let wide = std::ffi::OsStr::new(&name) + .encode_wide() + .chain(std::iter::once(0)) + .collect::>(); + // SAFETY: Null security attributes select defaults and `wide` is NUL-terminated. + let handle = unsafe { CreateJobObjectW(std::ptr::null(), wide.as_ptr()) }; + if handle.is_null() { + return Err(format!( + "failed to create detached sidecar Job Object: {}", + std::io::Error::last_os_error() + )); + } + 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 = std::io::Error::last_os_error(); + // SAFETY: `handle` was created above and has not been transferred. + unsafe { CloseHandle(handle) }; + return Err(format!( + "failed to configure detached sidecar Job Object cleanup: {error}" + )); + } + Ok(Self { handle, name }) + } + + #[cfg(test)] + pub(crate) fn name(&self) -> &str { + &self.name + } + + pub(super) fn configure_child(&self, command: &mut Command) { + command.env(SIDECAR_JOB_NAME_ENV, &self.name); + } + + #[cfg(test)] + pub(crate) fn assign(&self, child: &Child) -> Result<(), String> { + use std::os::windows::io::AsRawHandle; + use windows_sys::Win32::System::JobObjects::AssignProcessToJobObject; + + // SAFETY: Both handles are live kernel handles owned by this process. + let assigned = + unsafe { AssignProcessToJobObject(self.handle, child.as_raw_handle().cast()) }; + if assigned == 0 { + return Err(format!( + "failed to assign detached sidecar {} to its Job Object: {}", + child.id(), + std::io::Error::last_os_error() + )); + } + Ok(()) + } + + pub(crate) fn terminate(&self) { + use windows_sys::Win32::System::JobObjects::TerminateJobObject; + + // SAFETY: The retained handle owns the Job Object assigned to this sidecar process tree. + let _ = unsafe { TerminateJobObject(self.handle, 1) }; + } +} + +#[cfg(windows)] +pub(crate) fn join_sidecar_job_from_env() -> Result<(), String> { + use std::os::windows::ffi::OsStrExt; + use windows_sys::Win32::Foundation::CloseHandle; + use windows_sys::Win32::System::JobObjects::{AssignProcessToJobObject, OpenJobObjectW}; + use windows_sys::Win32::System::SystemServices::{ + JOB_OBJECT_ASSIGN_PROCESS, JOB_OBJECT_TERMINATE, + }; + use windows_sys::Win32::System::Threading::GetCurrentProcess; + + let Some(name) = env::var_os(SIDECAR_JOB_NAME_ENV) else { + return Ok(()); + }; + // SAFETY: Windows environment mutation is synchronized by the operating system. Remove the + // private handoff value before any plugin worker can inherit it. + unsafe { env::remove_var(SIDECAR_JOB_NAME_ENV) }; + let wide = name + .encode_wide() + .chain(std::iter::once(0)) + .collect::>(); + // SAFETY: `wide` is NUL-terminated and requests only assignment/termination rights. + let job = unsafe { + OpenJobObjectW( + JOB_OBJECT_ASSIGN_PROCESS | JOB_OBJECT_TERMINATE, + 0, + wide.as_ptr(), + ) + }; + if job.is_null() { + return Err(format!( + "failed to open detached sidecar Job Object; persistent bootstrap cannot guarantee process-tree cleanup: {}", + std::io::Error::last_os_error() + )); + } + // SAFETY: `job` is live and the pseudo current-process handle is valid. + let assigned = unsafe { AssignProcessToJobObject(job, GetCurrentProcess()) }; + if assigned == 0 { + let error = std::io::Error::last_os_error(); + // SAFETY: `job` was opened above and has not been transferred. + unsafe { CloseHandle(job) }; + return Err(format!( + "failed to join detached sidecar Job Object; the current Windows Job Object may reject nested assignment, so persistent bootstrap cannot guarantee process-tree cleanup: {error}" + )); + } + let retained = SidecarJob { + handle: job, + name: name.to_string_lossy().into_owned(), + }; + RETAINED_SIDECAR_JOB.set(retained).map_err(|_| { + "detached sidecar Job Object was initialized more than once in one process".to_string() + }) +} + +#[cfg(not(windows))] +pub(crate) fn join_sidecar_job_from_env() -> Result<(), String> { + Ok(()) +} + +#[cfg(windows)] +impl Drop for SidecarJob { + fn drop(&mut self) { + use windows_sys::Win32::Foundation::CloseHandle; + + // SAFETY: `handle` is uniquely owned by this wrapper and closed exactly once. + unsafe { CloseHandle(self.handle) }; + } +} + +#[cfg(unix)] +pub(crate) fn terminate_sidecar_process_tree(child: &mut Child) { + let process_group = -(child.id() as i32); + // SAFETY: The detached sidecar calls `setsid` before exec, so its PID is also the process-group + // ID. A negative PID targets that complete group and does not dereference memory. + if unsafe { libc::kill(process_group, libc::SIGKILL) } == -1 { + let _ = child.kill(); + } + let _ = child.wait(); +} + +#[cfg(windows)] +pub(crate) fn terminate_sidecar_process_tree(child: &mut Child) { + let status = Command::new("taskkill") + .args(["/PID", &child.id().to_string(), "/T", "/F"]) + .status(); + if !status.is_ok_and(|status| status.success()) { + let _ = child.kill(); + } + let _ = child.wait(); +} + +#[cfg(not(any(unix, windows)))] +pub(crate) fn terminate_sidecar_process_tree(child: &mut Child) { + let _ = child.kill(); + let _ = child.wait(); +} + +#[cfg(unix)] +pub(crate) fn configure_detached_sidecar(command: &mut Command) { + use std::os::unix::process::CommandExt; + + // SAFETY: `setsid` is async-signal-safe and has no memory-safety preconditions. It runs in the + // post-fork child before exec so the shared sidecar is outside the MCP client's session and + // process group. + 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) const WINDOWS_JOB_OBJECT_LIMIT_KILL_ON_CLOSE: u32 = 0x0000_2000; + +#[cfg(any(test, windows))] +pub(crate) fn windows_sidecar_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 for this + // query, and `in_job` points to writable storage for the result. + 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: A null job handle queries the job associated with the current process. The buffer is + // correctly sized and aligned for 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_sidecar(command: &mut Command) { + use std::os::windows::process::CommandExt; + + let (in_job, limits) = current_windows_job_limits(); + let (flags, limited_lifetime) = windows_sidecar_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" + ); + } + command.creation_flags(flags); +} + +#[cfg(not(any(unix, windows)))] +pub(crate) fn configure_detached_sidecar(_command: &mut Command) {} + +#[cfg(test)] +pub(crate) fn terminate_unready_sidecar( + mut child: Child, + pid_path: &std::path::Path, + url: &str, +) -> Result<(), String> { + match child.try_wait() { + Ok(Some(status)) => { + let _ = std::fs::remove_file(pid_path); + return Err(format!( + "nemo-relay sidecar exited before becoming ready at {url}: {status}" + )); + } + Ok(None) => {} + Err(error) => { + let _ = std::fs::remove_file(pid_path); + return Err(format!( + "failed to inspect nemo-relay sidecar process: {error}" + )); + } + } + if let Err(error) = child.kill() { + let _ = std::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 _ = std::fs::remove_file(pid_path); + Err(format!( + "nemo-relay sidecar did not become ready at {url}; terminated startup process" + )) +} diff --git a/crates/cli/src/sidecar/state.rs b/crates/cli/src/sidecar/state.rs new file mode 100644 index 000000000..0edb27200 --- /dev/null +++ b/crates/cli/src/sidecar/state.rs @@ -0,0 +1,495 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Per-user ownership, locking, and readiness state for managed sidecars. + +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::config::CodingAgent; +use crate::file_io::{LockAttempt, atomic_write, try_lock_exclusive}; + +use super::health::{RelayHealth, probe, request_shutdown}; +use super::{BOOTSTRAP_PROTOCOL_VERSION, GatewayEndpoint, SIDECAR_LOCK_TIMEOUT}; + +pub(crate) const BOOTSTRAP_AGENT_ENV: &str = "NEMO_RELAY_BOOTSTRAP_AGENT"; +pub(crate) const BOOTSTRAP_STATE_DIR_ENV: &str = "NEMO_RELAY_BOOTSTRAP_STATE_DIR"; + +#[derive(Debug, Deserialize, Serialize)] +pub(super) struct OwnerRecord { + service: String, + version: String, + bootstrap_protocol: u64, + pub(super) pid: u32, + pub(super) url: String, + shutdown_token: String, + bootstrap_fingerprint: Option, +} + +impl OwnerRecord { + fn new(pid: u32, url: &str, shutdown_token: &str, bootstrap_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: bootstrap_fingerprint.map(str::to_owned), + } + } + + fn matches( + &self, + pid: u32, + url: &str, + shutdown_token: &str, + bootstrap_fingerprint: Option<&str>, + ) -> bool { + self.service == "nemo-relay" + && self.version == env!("CARGO_PKG_VERSION") + && self.bootstrap_protocol == BOOTSTRAP_PROTOCOL_VERSION + && self.pid == pid + && self.url == url + && self.shutdown_token == shutdown_token + && self.bootstrap_fingerprint.as_deref() == bootstrap_fingerprint + } +} + +#[derive(Debug, Deserialize)] +struct ReadyRecord { + service: String, + version: String, + bootstrap_protocol: u64, + address: String, +} + +pub(super) fn read_owner_record(path: &Path) -> Result, String> { + let raw = match fs::read(path) { + Ok(raw) => raw, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => { + return Err(format!( + "failed to read sidecar ownership {}: {error}", + path.display() + )); + } + }; + serde_json::from_slice(&raw) + .map(Some) + .map_err(|error| format!("invalid sidecar ownership file {}: {error}", path.display())) +} + +pub(super) fn read_ready_file(path: &Path) -> Result, String> { + let raw = match fs::read(path) { + Ok(raw) => raw, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => { + return Err(format!( + "failed to read sidecar readiness file {}: {error}", + path.display() + )); + } + }; + let record = serde_json::from_slice::(&raw) + .map_err(|error| format!("invalid sidecar readiness file {}: {error}", path.display()))?; + if record.service != "nemo-relay" + || record.version != env!("CARGO_PKG_VERSION") + || record.bootstrap_protocol != BOOTSTRAP_PROTOCOL_VERSION + { + return Err(format!( + "incompatible sidecar readiness file {}", + path.display() + )); + } + let address = record + .address + .parse::() + .map_err(|error| format!("invalid sidecar address in {}: {error}", path.display()))?; + Ok(Some(GatewayEndpoint { + address, + url: format!("http://{address}"), + })) +} + +pub(crate) fn owner_path(runtime: &Path, agent: CodingAgent, url: &str) -> PathBuf { + runtime.join(format!( + "{}-sidecar-{}.owner.json", + agent.as_arg(), + lock_name(url) + )) +} + +pub(crate) fn pid_path(runtime: &Path, agent: CodingAgent, url: &str) -> PathBuf { + runtime.join(format!("{}-sidecar-{}.pid", agent.as_arg(), lock_name(url))) +} + +pub(crate) fn lock_path(runtime: &Path, url: &str) -> PathBuf { + runtime.join(format!("{}-sidecar.lock", lock_name(url))) +} + +pub(crate) fn state_dir() -> Result { + crate::config::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 lock_endpoint(runtime: &Path, url: &str) -> Result { + lock_endpoint_for(runtime, url, SIDECAR_LOCK_TIMEOUT) +} + +pub(crate) fn lock_endpoint_for( + runtime: &Path, + url: &str, + timeout: Duration, +) -> Result { + let path = lock_path(runtime, url); + let lock = open_lock(&path)?; + let deadline = Instant::now() + timeout; + loop { + match try_lock_exclusive(&lock) { + Ok(LockAttempt::Acquired) => return Ok(lock), + Ok(LockAttempt::Contended) => { + if Instant::now() >= deadline { + return Err(format!( + "timed out waiting for sidecar lock {}", + path.display() + )); + } + thread::sleep(Duration::from_millis(50)); + } + Err(error) => { + return Err(format!( + "failed to acquire sidecar lock {}: {error}", + path.display() + )); + } + } + } +} + +pub(super) fn open_lock(path: &Path) -> Result { + OpenOptions::new() + .create(true) + .truncate(false) + .read(true) + .write(true) + .open(path) + .map_err(|error| format!("failed to open sidecar lock {}: {error}", path.display())) +} + +pub(crate) fn write_owner( + path: &Path, + pid: u32, + url: &str, + shutdown_token: &str, + bootstrap_fingerprint: Option<&str>, +) -> Result<(), String> { + let record = OwnerRecord::new(pid, url, shutdown_token, bootstrap_fingerprint); + let bytes = serde_json::to_vec(&record) + .map_err(|error| format!("failed to encode sidecar ownership: {error}"))?; + atomic_write(path, &bytes) +} + +pub(crate) fn publish_owner_from_env(address: SocketAddr) -> Result<(), String> { + let state = env::var_os(BOOTSTRAP_STATE_DIR_ENV); + let agent = env::var(BOOTSTRAP_AGENT_ENV).ok(); + let token = env::var("NEMO_RELAY_BOOTSTRAP_SHUTDOWN_TOKEN").ok(); + let bootstrap_fingerprint = env::var(crate::config::BOOTSTRAP_FINGERPRINT_ENV) + .ok() + .filter(|fingerprint| !fingerprint.is_empty()); + if state.is_none() && agent.is_none() && token.is_none() { + return Ok(()); + } + 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 agent = match agent.as_deref() { + Some("codex") => CodingAgent::Codex, + Some("claude-code") => CodingAgent::ClaudeCode, + Some(other) => return Err(format!("unsupported bootstrap agent {other}")), + None => { + return Err(format!( + "{BOOTSTRAP_AGENT_ENV} is required for managed bootstrap" + )); + } + }; + let token = token.filter(|token| !token.is_empty()).ok_or_else(|| { + "NEMO_RELAY_BOOTSTRAP_SHUTDOWN_TOKEN is required for managed bootstrap".to_string() + })?; + if !address.ip().is_loopback() { + return Err(format!( + "managed bootstrap ownership requires a loopback address, got {address}" + )); + } + create_private_runtime_dir(&state).map_err(|error| { + format!( + "failed to create bootstrap state directory {}: {error}", + state.display() + ) + })?; + let url = format!("http://{address}"); + let pid = std::process::id(); + let owner_path = owner_path(&state, agent, &url); + let pid_path = pid_path(&state, agent, &url); + atomic_write(&pid_path, pid.to_string().as_bytes())?; + if let Err(error) = write_owner( + &owner_path, + pid, + &url, + &token, + bootstrap_fingerprint.as_deref(), + ) { + let _ = fs::remove_file(&pid_path); + return Err(error); + } + Ok(()) +} + +pub(crate) fn validate_owner( + owner_path: &Path, + pid_path: &Path, + pid: u32, + url: &str, + shutdown_token: &str, + bootstrap_fingerprint: Option<&str>, +) -> Result<(), String> { + let owner = read_owner_record(owner_path)?.ok_or_else(|| { + format!( + "failed to read sidecar ownership {}: file does not exist", + owner_path.display() + ) + })?; + let valid = owner.matches(pid, url, shutdown_token, bootstrap_fingerprint) + && fs::read_to_string(pid_path) + .ok() + .is_some_and(|value| value.trim() == pid.to_string()); + if valid { + Ok(()) + } else { + Err(format!( + "sidecar ownership {} does not match the ready process", + owner_path.display() + )) + } +} + +pub(crate) fn stop_owned(agent: CodingAgent) -> Result<(), String> { + let runtime = state_dir()?; + let mut errors = Vec::new(); + for owner_path in owner_paths(&runtime, agent)? { + if let Err(error) = stop_owned_record(agent, &runtime, &owner_path) { + errors.push(error); + } + } + if errors.is_empty() { + Ok(()) + } else { + Err(errors.join("; ")) + } +} + +pub(crate) fn owner_paths(runtime: &Path, agent: CodingAgent) -> Result, String> { + let entries = match fs::read_dir(runtime) { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(error) => { + return Err(format!( + "failed to enumerate sidecar ownership in {}: {error}", + runtime.display() + )); + } + }; + let prefix = format!("{}-sidecar-", agent.as_arg()); + let legacy = format!("{}-sidecar.owner.json", agent.as_arg()); + let mut paths = entries + .filter_map(Result::ok) + .filter_map(|entry| { + let name = entry.file_name(); + let name = name.to_str()?; + ((name == legacy || (name.starts_with(&prefix) && name.ends_with(".owner.json"))) + && entry.file_type().ok()?.is_file()) + .then(|| entry.path()) + }) + .collect::>(); + paths.sort(); + Ok(paths) +} + +pub(crate) fn stop_owned_record( + agent: CodingAgent, + runtime: &Path, + owner_path: &Path, +) -> Result<(), String> { + let Some(initial_owner) = read_owner_record(owner_path)? else { + return Ok(()); + }; + let _lock = lock_endpoint(runtime, &initial_owner.url)?; + let Some(owner) = read_owner_record(owner_path)? else { + return Ok(()); + }; + let url = owner.url.as_str(); + let token = (!owner.shutdown_token.is_empty()) + .then_some(owner.shutdown_token.as_str()) + .ok_or_else(|| { + format!( + "sidecar ownership {} has no shutdown token", + owner_path.display() + ) + })?; + let bootstrap_fingerprint = owner + .bootstrap_fingerprint + .as_deref() + .filter(|fingerprint| !fingerprint.is_empty()) + .ok_or_else(|| { + format!( + "sidecar ownership {} has no authenticated bootstrap fingerprint", + owner_path.display() + ) + })?; + let legacy = owner_path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name == format!("{}-sidecar.owner.json", agent.as_arg())); + let pid_path = if legacy { + runtime.join(format!("{}-sidecar.pid", agent.as_arg())) + } else { + pid_path(runtime, agent, url) + }; + match probe(url, Some(bootstrap_fingerprint)) { + RelayHealth::Unavailable => { + let _ = fs::remove_file(owner_path); + let _ = fs::remove_file(&pid_path); + return Ok(()); + } + RelayHealth::Compatible => {} + RelayHealth::Incompatible | RelayHealth::Foreign => { + return Err(format!( + "refusing to stop {url}: sidecar ownership points to a foreign listener" + )); + } + } + request_shutdown(url, token)?; + let deadline = Instant::now() + Duration::from_secs(5); + while Instant::now() < deadline { + if probe(url, Some(bootstrap_fingerprint)) == RelayHealth::Unavailable { + let _ = fs::remove_file(owner_path); + let _ = fs::remove_file(&pid_path); + return Ok(()); + } + thread::sleep(Duration::from_millis(50)); + } + Err(format!( + "timed out waiting for managed sidecar at {url} to stop" + )) +} + +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(), + verified_runtime_user(), + None, + ) +} + +#[cfg(unix)] +fn verified_runtime_user() -> Option { + // SAFETY: `geteuid` has no preconditions and does not dereference pointers. + Some(format!("uid-{}", unsafe { libc::geteuid() }).into()) +} + +#[cfg(not(unix))] +fn verified_runtime_user() -> Option { + env::var_os("USERNAME").or_else(|| env::var_os("USER")) +} + +pub(crate) 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 { + return PathBuf::from(base).join("nemo-relay-plugin"); + } + PathBuf::from(tmpdir.or(temp).unwrap_or_else(|| temp_dir.into_os_string())) + .join(runtime_user_segment(user, username)) + .join("nemo-relay-plugin") +} + +pub(super) fn create_private_runtime_dir(path: &Path) -> std::io::Result<()> { + fs::create_dir_all(path)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + + fs::set_permissions(path, fs::Permissions::from_mode(0o700))?; + } + Ok(()) +} + +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()); + 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 + } +} + +#[cfg(test)] +#[path = "../../tests/coverage/sidecar_state_tests.rs"] +mod tests; diff --git a/crates/cli/tests/cli_tests.rs b/crates/cli/tests/cli_tests.rs index 91dfa0b71..575c1c1b3 100644 --- a/crates/cli/tests/cli_tests.rs +++ b/crates/cli/tests/cli_tests.rs @@ -3,13 +3,17 @@ //! 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::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}; @@ -161,6 +165,646 @@ fn cli_version_exits_successfully() { assert!(String::from_utf8_lossy(&output.stdout).contains("nemo-relay ")); } +#[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_mcp_initializes_and_exits_cleanly_when_stdio_closes() { + 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", "1") + .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(), + "{}", + 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") + ); + let log = std::fs::read_to_string( + find_runtime_file(temp.path(), "codex-sidecar.log") + .expect("Codex sidecar log should exist"), + ) + .unwrap(); + assert!(log.contains("Gateway http://127.0.0.1:")); +} + +#[test] +fn cli_mcp_does_not_launch_gateway_when_stdio_closes_before_request() { + let temp = tempfile::tempdir().unwrap(); + let mut child = Command::new(gateway_bin()) + .args(["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()) + .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(), "codex-sidecar.log").is_none()); +} + +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) { + let mut child = Command::new(gateway_bin()) + .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) + .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_codex_hook_cold_recovery_uses_the_mcp_persistent_identity() { + 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([ + "plugin-shim", + "hook", + "codex", + "--gateway-url", + &gateway_url, + ]) + .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(), + "cold hook recovery failed: {}", + String::from_utf8_lossy(&hook_output.stderr) + ); + + let (mut mcp, mcp_stdin) = start_mcp_client_with_idle_timeout(temp.path(), address, "10"); + drop(mcp_stdin); + assert!(wait_child(&mut mcp).success()); + + let owner = wait_for_owned_sidecar(temp.path(), None); + assert_eq!(owner["url"], gateway_url); + 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 run_fake_bootstrap_listener(proof: FakeBootstrapProof) -> (Output, Vec) { + let temp = tempfile::tempdir().unwrap(); + 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 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 => { + let key = std::fs::read(&key_path).unwrap(); + format!( + "X-NeMo-Relay-Bootstrap-Proof: {}\r\n", + fake_bootstrap_proof(&key, fingerprint, nonce) + ) + } + }; + let body = format!( + r#"{{"status":"ok","service":"nemo-relay","version":"{}","bootstrap_protocol":1}}"#, + 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(); + } else { + let body = r#"{"continue":true}"#; + 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(), + ) + .unwrap(); + } + } + }); + + let mut child = Command::new(gateway_bin()) + .args([ + "plugin-shim", + "hook", + "codex", + "--gateway-url", + &format!("http://{address}"), + ]) + .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 ")) + ); +} + +fn run_codex_hook_with_launch_resolution_error( + temp: &std::path::Path, + fail_closed: bool, + payload: &[u8], +) -> Output { + let mut command = Command::new(gateway_bin()); + command + .args([ + "plugin-shim", + "hook", + "codex", + "--gateway-url", + "http://127.0.0.1:1", + ]) + .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", "not-a-number") + .env_remove("NEMO_RELAY_FAIL_CLOSED") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + 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"{}"); + assert_eq!(output.status.success(), !fail_closed); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("gateway identity preflight failed")); + 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); + + 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("gateway identity preflight failed")); + assert!(output.stdout.is_empty()); + } +} + +fn sidecar_address(temp: &std::path::Path) -> SocketAddr { + let deadline = Instant::now() + Duration::from_secs(5); + loop { + let log_path = find_runtime_file(temp, "codex-sidecar.log"); + if let Some(log_path) = log_path.as_ref() + && let Ok(log) = std::fs::read_to_string(log_path) + && let Some(address) = log.lines().find_map(|line| { + line.split("Gateway http://") + .nth(1) + .and_then(|value| value.split_whitespace().next()) + .and_then(|value| value.parse().ok()) + }) + { + return address; + } + assert!( + Instant::now() < deadline, + "sidecar address was not logged under {}; log: {:?}", + temp.display(), + log_path.and_then(|path| std::fs::read_to_string(path).ok()) + ); + 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(); + let mut stderr = String::new(); + if let Some(mut child_stderr) = child.stderr.take() { + let _ = child_stderr.read_to_string(&mut stderr); + } + panic!("child process did not exit within 10 seconds; stderr: {stderr}"); + } + thread::sleep(Duration::from_millis(20)); + } +} + +fn wait_child_with_output(mut child: Child) -> Output { + let deadline = Instant::now() + Duration::from_secs(10); + loop { + if child.try_wait().unwrap().is_some() { + return child.wait_with_output().unwrap(); + } + if Instant::now() >= deadline { + let _ = child.kill(); + let output = child.wait_with_output().unwrap(); + panic!( + "child process did not exit within 10 seconds; stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + thread::sleep(Duration::from_millis(20)); + } +} + +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, "codex-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 Codex 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"], 1); + + 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_agents_json_emits_supported_agent_shapes() { let temp = tempfile::tempdir().unwrap(); @@ -1272,7 +1916,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}" ); } diff --git a/crates/cli/tests/coverage/config_tests.rs b/crates/cli/tests/coverage/config_tests.rs index d1a3fc3d3..d56bfdc84 100644 --- a/crates/cli/tests/coverage/config_tests.rs +++ b/crates/cli/tests/coverage/config_tests.rs @@ -25,6 +25,10 @@ struct PluginConfigDiscoveryScope { _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 { @@ -34,14 +38,40 @@ impl PluginConfigDiscoveryScope { .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 { _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 +84,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), + } } } } @@ -113,6 +159,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 +229,7 @@ enabled = false items = [{capabilities}] [source] +manifest_root = "." artifact = "plugin.py" [integrity] @@ -170,7 +238,7 @@ sha256 = "{digest}" [load] runtime = "python" -entrypoint = "{plugin_id}.plugin:register" +entrypoint = "plugin:register" "#, capabilities = capabilities, signature_line = signature_line, @@ -597,6 +665,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(); @@ -1425,6 +1522,9 @@ 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 { @@ -1433,6 +1533,7 @@ fn server_resolution_applies_all_server_overrides() { 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 +1546,526 @@ 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 = ServerArgs { + config: Some(config_path), + bind: Some("127.0.0.1:0".parse().unwrap()), + ..ServerArgs::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() + ); +} + +#[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 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 = ServerArgs { + bind: Some("127.0.0.1:47632".parse().unwrap()), + ..ServerArgs::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(&ServerArgs::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 = ServerArgs { + ready_file: Some(temp.path().join("managed.ready.json")), + ..ServerArgs::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 codex_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::sidecar::resolve_codex_gateway(&ServerArgs::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); +} + +#[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(&ServerArgs::default()).unwrap(); + assert_eq!( + crate::plugins::lifecycle::test_python_environment_digest_calls(), + 0, + "persistent hook identity must trust only the authenticated environment marker" + ); + + std::fs::write( + environment.join("site-packages/fixture.py"), + b"fixture = 'mutated'\n", + ) + .unwrap(); + let after = resolve_persistent_server_config(&ServerArgs::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 resolved = load_shared_config_scoped(None, None, true).unwrap(); + 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 = ServerArgs { + ready_file: Some(temp.path().join("managed.ready.json")), + ..ServerArgs::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("identity budget")); +} + +#[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(&ServerArgs::default()) + .unwrap_err() + .to_string(); + + assert!(error.contains("dynamic plugin manifest")); + assert!(error.contains("identity budget")); +} + +#[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(&ServerArgs::default()) + .unwrap_err() + .to_string(); + + assert!(error.contains("dynamic plugin artifact")); + assert!(error.contains("identity budget")); +} + +#[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 = ServerArgs { + config: Some(PathBuf::from("project-config.toml")), + ..ServerArgs::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(); diff --git a/crates/cli/tests/coverage/file_io_tests.rs b/crates/cli/tests/coverage/file_io_tests.rs new file mode 100644 index 000000000..47fa6c93b --- /dev/null +++ b/crates/cli/tests/coverage/file_io_tests.rs @@ -0,0 +1,47 @@ +// 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(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/gateway_tests.rs b/crates/cli/tests/coverage/gateway_tests.rs index 17d55f8ca..c81871457 100644 --- a/crates/cli/tests/coverage/gateway_tests.rs +++ b/crates/cli/tests/coverage/gateway_tests.rs @@ -745,9 +745,12 @@ async fn passthrough_rejects_unsupported_provider_path_directly() { }; let state = AppState { config: config.clone(), + bootstrap_fingerprint: None, + bootstrap_challenge_key: None, 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, }; let request = Request::builder() .method(Method::POST) @@ -774,9 +777,12 @@ async fn models_rejects_non_get_requests_directly() { }; let state = AppState { config: config.clone(), + bootstrap_fingerprint: None, + bootstrap_challenge_key: None, 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, }; let request = Request::builder() .method(Method::POST) diff --git a/crates/cli/tests/coverage/install_generation_tests.rs b/crates/cli/tests/coverage/install_generation_tests.rs new file mode 100644 index 000000000..257c883c7 --- /dev/null +++ b/crates/cli/tests/coverage/install_generation_tests.rs @@ -0,0 +1,160 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use std::fs::{File, OpenOptions}; + +use tempfile::tempdir; + +use super::*; + +#[test] +fn generation_markers_have_one_canonical_encoding() { + let active = GenerationMarker::Active("generation-a".into()); + let retired = GenerationMarker::Retired("generation-a".into()); + + assert_eq!(active.token(), "generation-a"); + assert_eq!(retired.token(), "generation-a"); + assert_eq!(active.encoded(), "generation-a\n"); + assert_eq!(retired.encoded(), "retired:generation-a\n"); + assert!(!active.is_retired()); + assert!(retired.is_retired()); +} + +#[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 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 = OpenOptions::new() + .read(true) + .write(true) + .open(&path) + .unwrap(); + let mut retirement = GenerationRetirement { + lock: Some(lock), + path: path.clone(), + original: GenerationMarker::Active("generation-a".into()), + changed: true, + }; + + retirement.restore_after_rollback().unwrap(); + + assert_eq!(std::fs::read_to_string(path).unwrap(), "generation-a\n"); + assert!(!retirement.changed); +} + +#[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, + path: path.clone(), + original: GenerationMarker::Active("generation-a".into()), + changed: 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 rollback_reports_when_an_invalidated_marker_cannot_be_reopened() { + let dir = tempdir().unwrap(); + let path = dir.path().join("missing-generation"); + let mut retirement = GenerationRetirement { + lock: None, + path: path.clone(), + original: GenerationMarker::Active("generation-a".into()), + changed: true, + }; + + let error = retirement.restore_after_rollback().unwrap_err(); + + assert!(error.contains("failed to reopen"), "{error}"); + assert!(error.contains(&path.display().to_string()), "{error}"); +} + +#[test] +fn marker_replacement_preserves_the_operation_in_io_errors() { + let dir = tempdir().unwrap(); + let path = dir.path().join(GENERATION_FILE_NAME); + std::fs::write(&path, "generation-a\n").unwrap(); + let mut read_only = File::open(&path).unwrap(); + + let error = replace_generation_marker( + &mut read_only, + &path, + &GenerationMarker::Retired("generation-a".into()), + "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 write"), "{error}"); +} + +#[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/installer_tests.rs b/crates/cli/tests/coverage/installer_tests.rs index 9a5b96073..312d2936e 100644 --- a/crates/cli/tests/coverage/installer_tests.rs +++ b/crates/cli/tests/coverage/installer_tests.rs @@ -137,6 +137,36 @@ fn generated_hook_dispatch_covers_all_agents() { ); } +#[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")) @@ -168,6 +198,15 @@ fn packaged_plugin_hooks_use_expected_shim_commands() { ) .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!("nemo-relay plugin-shim hook claude") @@ -176,6 +215,10 @@ fn packaged_plugin_hooks_use_expected_shim_commands() { codex["hooks"]["SessionStart"][0]["hooks"][0]["command"], json!("nemo-relay plugin-shim hook codex") ); + assert_eq!( + codex["hooks"], + generated_hooks(CodingAgent::Codex, "nemo-relay plugin-shim hook codex")["hooks"] + ); assert!( claude["hooks"] .as_object() @@ -216,6 +259,24 @@ fn packaged_plugin_manifests_use_stable_plugin_name_and_version() { 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)); + let env_vars = server["env_vars"].as_array().unwrap(); + assert!(env_vars.contains(&json!("OPENAI_API_KEY"))); + assert!(env_vars.contains(&json!("XDG_CONFIG_HOME"))); let codex_marketplace_path = root.join("../../.agents/plugins/marketplace.json"); let codex_marketplace = @@ -253,6 +314,7 @@ fn packaged_plugin_helpers_are_present() { for path in [ root.join("claude-code/hooks/hooks.json"), root.join("codex/hooks/hooks.json"), + root.join("codex/.mcp.json"), ] { let metadata = std::fs::metadata(&path) .unwrap_or_else(|error| panic!("{} missing: {error}", path.display())); diff --git a/crates/cli/tests/coverage/main_tests.rs b/crates/cli/tests/coverage/main_tests.rs index 85d512952..134f586ef 100644 --- a/crates/cli/tests/coverage/main_tests.rs +++ b/crates/cli/tests/coverage/main_tests.rs @@ -83,6 +83,17 @@ 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"); +} + #[test] fn safe_dispatch_helpers_cover_completions_and_plugins_paths() { let temp = tempfile::tempdir().unwrap(); diff --git a/crates/cli/tests/coverage/mcp_gateway_tests.rs b/crates/cli/tests/coverage/mcp_gateway_tests.rs new file mode 100644 index 000000000..fde004199 --- /dev/null +++ b/crates/cli/tests/coverage/mcp_gateway_tests.rs @@ -0,0 +1,69 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use super::*; + +#[test] +fn recovery_requires_three_consecutive_failures() { + let mut recovery = RecoveryState::default(); + + assert!(!recovery.record_failure().unwrap()); + assert!(!recovery.record_failure().unwrap()); + recovery.record_healthy(); + assert!(!recovery.record_failure().unwrap()); + assert!(!recovery.record_failure().unwrap()); + assert!(recovery.record_failure().unwrap()); +} + +#[test] +fn rediscovery_preserves_the_single_restart_allowance() { + let mut recovery = RecoveryState::default(); + for _ in 0..2 { + assert!(!recovery.record_failure().unwrap()); + } + assert!(recovery.record_failure().unwrap()); + recovery.record_recovery(false); + + for _ in 0..2 { + assert!(!recovery.record_failure().unwrap()); + } + assert!(recovery.record_failure().unwrap()); + recovery.record_recovery(true); + + for _ in 0..2 { + assert!(!recovery.record_failure().unwrap()); + } + let error = recovery.record_failure().unwrap_err(); + assert!(error.to_string().contains("after its coordinated restart")); +} + +#[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 }); + + tokio::time::timeout(Duration::from_secs(1), dropped_rx) + .await + .expect("gateway monitor was not aborted when its lease dropped") + .unwrap(); +} diff --git a/crates/cli/tests/coverage/mcp_tests.rs b/crates/cli/tests/coverage/mcp_tests.rs new file mode 100644 index 000000000..01d6fb364 --- /dev/null +++ b/crates/cli/tests/coverage/mcp_tests.rs @@ -0,0 +1,703 @@ +// 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::config::CodingAgent; +use crate::install_generation::{ + GENERATION_FILE_NAME, GenerationRetirement, InstallGeneration, write_new_generation, +}; + +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") }; + } + } +} + +#[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() { + let response = response_for(&json!({ + "jsonrpc": "2.0", + "id": 7, + "method": "initialize", + "params": { "protocolVersion": "2024-11-05" } + })) + .unwrap(); + + assert_eq!(response["jsonrpc"], json!("2.0")); + assert_eq!(response["id"], json!(7)); + assert_eq!( + response["result"]["protocolVersion"], + json!(MCP_PROTOCOL_VERSION) + ); + assert_eq!(response["result"]["capabilities"], json!({ "tools": {} })); + assert_eq!( + response["result"]["serverInfo"]["name"], + json!("nemo-relay") + ); + assert_eq!( + response["result"]["serverInfo"]["version"], + json!(env!("CARGO_PKG_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")) + ); +} + +#[test] +fn gateway_bootstrap_is_deferred_until_initialize() { + assert_eq!(default_mcp_bind(), "127.0.0.1:47632".parse().unwrap()); + assert!(!request_requires_gateway("not-json\n")); + assert!(!request_requires_gateway( + r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"# + )); + assert!(!request_requires_gateway( + r#"{"jsonrpc":"2.0","id":1,"method":"ping"}"# + )); + assert!(!request_requires_gateway( + r#"{"jsonrpc":"2.0","method":"initialize","params":{"protocolVersion":"2025-06-18"}}"# + )); + assert!(!request_requires_gateway( + r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}"# + )); + assert!(!request_requires_gateway( + r#"{"jsonrpc":"1.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18"}}"# + )); + assert!(request_requires_gateway( + r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18"}}"# + )); +} + +#[test] +fn invalid_jsonrpc_never_bootstraps_and_returns_invalid_request() { + let action = crate::mcp::protocol::evaluate_frame( + r#"{"jsonrpc":"1.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18"}}"#, + ); + + assert!(!action.requires_gateway); + 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( + "127.0.0.1:9".parse().unwrap(), + "http://127.0.0.1:9".into(), + Vec::new(), + "test-fingerprint".into(), + Duration::from_secs(60), + 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::config::GatewayConfig { + bind, + ..crate::config::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::sidecar::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::sidecar::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| { + restart_calls.fetch_add(1, Ordering::SeqCst); + async move { + Ok(crate::sidecar::GatewayBootstrap { + endpoint: crate::sidecar::GatewayEndpoint { + address, + url: "http://unexpected-restart".into(), + }, + started: false, + }) + } + } + }, + )); + + 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 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| { + let restart_calls = restart_calls.clone(); + async move { + restart_calls.fetch_add(1, Ordering::SeqCst); + Ok(crate::sidecar::GatewayBootstrap { + endpoint: crate::sidecar::GatewayEndpoint { + address, + url: "http://recovered-gateway".into(), + }, + started: true, + }) + } + } + }, + )); + + 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| { + restart_calls.fetch_add(1, Ordering::SeqCst); + async move { + Ok(crate::sidecar::GatewayBootstrap { + endpoint: crate::sidecar::GatewayEndpoint { + address, + url: "http://gateway".into(), + }, + started: false, + }) + } + } + }, + )); + + 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_does_not_consume_the_actual_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| { + let restart_calls = restart_calls.clone(); + async move { + let attempt = restart_calls.fetch_add(1, Ordering::SeqCst); + Ok(crate::sidecar::GatewayBootstrap { + endpoint: crate::sidecar::GatewayEndpoint { + address, + url: "http://gateway".into(), + }, + started: attempt > 0, + }) + } + } + }, + ) + .await + .unwrap_err(); + + assert_eq!(restart_calls.load(Ordering::SeqCst), 2); + 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| 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| { + let restart_calls = restart_calls.clone(); + async move { + restart_calls.fetch_add(1, Ordering::SeqCst); + Ok(crate::sidecar::GatewayBootstrap { + endpoint: crate::sidecar::GatewayEndpoint { + address, + url: "http://still-unhealthy".into(), + }, + started: true, + }) + } + } + }, + ) + .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 generation_path = dir.path().join(GENERATION_FILE_NAME); + write_new_generation(&generation_path).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| { + restart_calls.fetch_add(1, Ordering::SeqCst); + async move { + Ok(crate::sidecar::GatewayBootstrap { + endpoint: crate::sidecar::GatewayEndpoint { + address, + url: "http://unexpected-restart".into(), + }, + started: true, + }) + } + } + }, + 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(); + std::fs::rename(&generation_path, dir.path().join("retired-generation")).unwrap(); + write_new_generation(&generation_path).unwrap(); + 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 error = InstallGeneration::capture(generation_path.clone()).unwrap_err(); + assert!(error.contains("has been retired"), "{error}"); + + retirement.restore_after_rollback().unwrap(); + original.verify_current().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(); + drop(retirement); + + let mut resumed = GenerationRetirement::acquire(&generation_path) + .unwrap() + .expect("a retired generation should support cleanup retry"); + resumed.invalidate_for_replacement().unwrap(); + drop(resumed); + + let error = InstallGeneration::capture(generation_path).unwrap_err(); + assert!(error.contains("has been retired"), "{error}"); +} + +#[test] +fn retired_mcp_postcheck_does_not_stop_a_replacement_gateway() { + assert_retired_mcp_postcheck_preserves_gateway(2002, "replacement-token"); +} + +#[test] +fn retired_mcp_postcheck_does_not_stop_the_exact_reused_gateway() { + assert_retired_mcp_postcheck_preserves_gateway(1001, "retired-token"); +} + +fn assert_retired_mcp_postcheck_preserves_gateway(gateway_pid: u32, gateway_token: &str) { + let dir = tempfile::tempdir().unwrap(); + let generation_path = dir.path().join(GENERATION_FILE_NAME); + write_new_generation(&generation_path).unwrap(); + let generation = InstallGeneration::capture(generation_path.clone()).unwrap(); + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + listener.set_nonblocking(true).unwrap(); + let address = listener.local_addr().unwrap(); + let url = format!("http://{address}"); + let state = dir.path().join("bootstrap-state"); + std::fs::create_dir(&state).unwrap(); + let owner_path = crate::sidecar::sidecar_owner_path(&state, CodingAgent::Codex, &url); + let pid_path = crate::sidecar::sidecar_pid_path(&state, CodingAgent::Codex, &url); + crate::sidecar::write_sidecar_owner( + &owner_path, + gateway_pid, + &url, + gateway_token, + Some("same-bootstrap-fingerprint"), + ) + .unwrap(); + std::fs::write(&pid_path, gateway_pid.to_string()).unwrap(); + write_new_generation(&generation_path).unwrap(); + + let error = verify_bootstrap_generation(&generation).unwrap_err(); + + assert!(error.contains("has been retired"), "{error}"); + assert!(owner_path.exists()); + assert_eq!( + std::fs::read_to_string(pid_path).unwrap(), + gateway_pid.to_string() + ); + assert_eq!( + listener.accept().unwrap_err().kind(), + std::io::ErrorKind::WouldBlock + ); +} + +#[test] +fn default_mcp_gateway_uses_plugin_provider_port() { + assert_eq!(default_mcp_bind().to_string(), "127.0.0.1:47632"); +} diff --git a/crates/cli/tests/coverage/plugin_install_setup_tests.rs b/crates/cli/tests/coverage/plugin_install_setup_tests.rs new file mode 100644 index 000000000..9381b3c66 --- /dev/null +++ b/crates/cli/tests/coverage/plugin_install_setup_tests.rs @@ -0,0 +1,97 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use serde_json::{Value, json}; + +use super::*; + +struct DefaultsOnlyRunner; + +impl PluginSetupRunner for DefaultsOnlyRunner { + fn setup( + &self, + _host: PluginHost, + _gateway_url: &str, + _plugin_root: &Path, + ) -> Result<(), String> { + Ok(()) + } + + fn uninstall( + &self, + _host: PluginHost, + _gateway_url: &str, + _plugin_root: &Path, + ) -> Result<(), String> { + Ok(()) + } + + fn doctor( + &self, + _host: PluginHost, + _gateway_url: &str, + _plugin_root: &Path, + ) -> Result<(), String> { + Ok(()) + } + + fn doctor_json( + &self, + _host: PluginHost, + _gateway_url: &str, + _plugin_root: &Path, + ) -> Result { + Ok(json!({})) + } +} + +#[test] +fn setup_runner_defaults_are_explicit_no_ops() { + let runner = DefaultsOnlyRunner; + + assert!(runner.snapshot(PluginHost::Codex).unwrap().is_none()); + runner.restore_snapshot(&PluginSetupSnapshot::Mock).unwrap(); + runner.refresh_gateway(PluginHost::Codex).unwrap(); +} + +#[test] +fn real_runner_has_no_claude_snapshot_or_gateway_to_restore() { + let runner = RealPluginSetupRunner; + + assert!(runner.snapshot(PluginHost::ClaudeCode).unwrap().is_none()); + runner.restore_snapshot(&PluginSetupSnapshot::Mock).unwrap(); + runner.refresh_gateway(PluginHost::ClaudeCode).unwrap(); +} + +#[test] +fn setup_descriptions_reject_unexpanded_hosts_and_unknown_actions() { + assert!( + std::panic::catch_unwind(|| setup_action_description(PluginHost::All, "configure")) + .is_err() + ); + assert!( + std::panic::catch_unwind(|| setup_action_description(PluginHost::Codex, "unknown")) + .is_err() + ); + + let runner = RealPluginSetupRunner; + let root = Path::new("unused"); + assert!(std::panic::catch_unwind(|| runner.snapshot(PluginHost::All)).is_err()); + assert!(std::panic::catch_unwind(|| runner.refresh_gateway(PluginHost::All)).is_err()); + assert!( + std::panic::catch_unwind(|| runner.setup(PluginHost::All, DEFAULT_GATEWAY_URL, root)) + .is_err() + ); + assert!( + std::panic::catch_unwind(|| runner.uninstall(PluginHost::All, DEFAULT_GATEWAY_URL, root)) + .is_err() + ); + assert!( + std::panic::catch_unwind(|| runner.doctor(PluginHost::All, DEFAULT_GATEWAY_URL, root)) + .is_err() + ); + assert!( + std::panic::catch_unwind(|| runner.doctor_json(PluginHost::All, DEFAULT_GATEWAY_URL, root)) + .is_err() + ); +} diff --git a/crates/cli/tests/coverage/plugin_install_tests.rs b/crates/cli/tests/coverage/plugin_install_tests.rs index 5a0776dd8..463a9af65 100644 --- a/crates/cli/tests/coverage/plugin_install_tests.rs +++ b/crates/cli/tests/coverage/plugin_install_tests.rs @@ -2,10 +2,13 @@ // SPDX-License-Identifier: Apache-2.0 use std::cell::RefCell; -use std::collections::HashMap; +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; @@ -13,18 +16,225 @@ 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, + validate_codex_version, validate_host_registration, validate_relay_mcp, + validate_relay_plugin_shim, }; use super::*; +use crate::plugin_shim::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"; + +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 pending = spawn_default_host_plugin_readiness(PluginHost::Codex, dir.path().to_path_buf()); + let readiness = receive_host_plugin_readiness(pending, Duration::from_secs(5)); + assert_eq!(readiness.host, "codex"); + assert!(!readiness.checks.is_empty()); + + let (sender, receiver) = std::sync::mpsc::sync_channel(1); + drop(sender); + let readiness = receive_host_plugin_readiness( + PendingHostPluginReadiness { + host: PluginHost::ClaudeCode, + state_path: dir.path().join("claude-state.json"), + receiver, + }, + Duration::from_secs(1), + ); + assert!(!readiness.ok()); + assert!( + readiness.checks[0] + .details + .contains("collector stopped unexpectedly") + ); +} + +#[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(); + + 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(); + + 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(); + 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(PluginHost::ClaudeCode, dir.path()); + let runner = MockRunner::default(); + let setup_runner = MockSetupRunner::default(); + + force_cleanup_existing_install( + PluginHost::ClaudeCode, + &layout, + &dry_run, + &runner, + &setup_runner, + ) + .unwrap(); + rollback_install( + PluginHost::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(PluginHost::Codex, &staged_parent), + parent: staged_parent, + }; + let target = PluginLayout::new(PluginHost::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 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_codex_version(&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_codex_version(&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_codex_version(&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_codex_version(&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_codex_version(&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, } impl<'a> HomeScope<'a> { @@ -34,15 +244,18 @@ impl<'a> HomeScope<'a> { .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"); // 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"); } Self { _guard: guard, prev_home, prev_userprofile, + prev_codex_home, } } } @@ -59,6 +272,10 @@ impl Drop for HomeScope<'_> { 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"), + } } } } @@ -66,21 +283,29 @@ impl Drop for HomeScope<'_> { struct PathScope<'a> { _guard: std::sync::MutexGuard<'a, ()>, previous: Option, + previous_home: Option, + previous_codex_home: Option, } impl<'a> PathScope<'a> { - fn set(path: &Path) -> Self { + 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"); // 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"); } Self { _guard: guard, previous, + previous_home, + previous_codex_home, } } } @@ -93,23 +318,38 @@ impl Drop for PathScope<'_> { 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"), + } } } } #[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 @@ -139,6 +379,61 @@ impl MockRunner { 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_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() } @@ -153,6 +448,13 @@ impl MockRunner { } 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()) } @@ -210,11 +512,25 @@ impl CommandRunner for MockRunner { .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(|| CommandOutput::success(String::new()))) + .unwrap_or_else(|| { + if rendered.ends_with("codex --version") { + CommandOutput::success("codex-cli 0.143.0\n".into()) + } else { + CommandOutput::success(String::new()) + } + })) } } @@ -225,6 +541,7 @@ fn command_matches_suffix(command: &str, suffix: Option<&str>) -> bool { #[derive(Default)] struct MockSetupRunner { calls: RefCell>, + doctor_roots: RefCell>, failing_call: Option, } @@ -232,18 +549,50 @@ 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 setup(&self, host: PluginHost, gateway_url: &str) -> Result<(), String> { + fn snapshot(&self, host: PluginHost) -> Result, String> { + self.record(format!("snapshot {}", host_arg(host)))?; + Ok(Some(PluginSetupSnapshot::Mock)) + } + + fn restore_snapshot(&self, _snapshot: &PluginSetupSnapshot) -> Result<(), String> { + self.record("restore snapshot".into()) + } + + fn refresh_gateway(&self, host: PluginHost) -> Result<(), String> { + self.record(format!("refresh {}", host_arg(host))) + } + + fn setup( + &self, + host: PluginHost, + gateway_url: &str, + _plugin_root: &Path, + ) -> Result<(), String> { self.record(format!("setup {} {gateway_url}", host_arg(host))) } - fn uninstall(&self, host: PluginHost, gateway_url: &str) -> Result<(), String> { + fn uninstall( + &self, + host: PluginHost, + gateway_url: &str, + _plugin_root: &Path, + ) -> Result<(), String> { self.record(format!("uninstall {} {gateway_url}", host_arg(host))) } - fn doctor(&self, host: PluginHost, gateway_url: &str) -> Result<(), String> { + fn doctor( + &self, + host: PluginHost, + gateway_url: &str, + _plugin_root: &Path, + ) -> Result<(), String> { self.record(format!("doctor {} {gateway_url}", host_arg(host))) } @@ -251,7 +600,11 @@ impl PluginSetupRunner for MockSetupRunner { &self, host: PluginHost, gateway_url: &str, + plugin_root: &Path, ) -> Result { + self.doctor_roots + .borrow_mut() + .push(plugin_root.to_path_buf()); self.record(format!("doctor-json {} {gateway_url}", host_arg(host)))?; Ok(json!({ "ok": true, @@ -274,6 +627,7 @@ impl MockSetupRunner { 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, @@ -284,13 +638,323 @@ fn relay_validation_command() -> String { "/bin/nemo-relay plugin-shim hook --help".into() } +fn relay_mcp_validation_command() -> String { + "/bin/nemo-relay mcp --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_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(); } +fn write_relocated_codex_install(selected_dir: &Path, relocated_dir: &Path) -> PluginLayout { + let relocated = PluginLayout::new(PluginHost::Codex, relocated_dir); + write_plugin_marketplace( + PluginHost::Codex, + &relocated, + Path::new("/bin/nemo-relay"), + &options(selected_dir), + ) + .unwrap(); + write_state_for_host( + PluginHost::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", + "plugin_install::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( + PluginHost::Codex, + &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( + PluginHost::Codex, + &install_options, + &runner, + &setup_runner, + Duration::from_millis(75), + ) + .unwrap_err(); + + 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( + PluginHost::Codex, + &install_options, + &runner, + &setup_runner, + Duration::from_millis(75), + ) + .unwrap_err(); + + 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( + PluginHost::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 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::install_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!( @@ -340,34 +1004,252 @@ fn plugin_manifests_and_hooks_use_path_based_relay_command() { json!(PLUGIN_NAME) ); assert_eq!( - plugin_hooks(PluginHost::Codex)["hooks"]["SessionStart"][0]["hooks"][0]["command"], - json!("nemo-relay plugin-shim hook codex") + plugin_manifest(PluginHost::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( + PluginHost::Codex, + Path::new("/bin/nemo-relay"), + &generation_fence, + ) + .unwrap() + .unwrap(); + let server = &mcp["nemo-relay"]; + assert_eq!(server["command"], json!("/bin/nemo-relay")); + assert_eq!(server["args"], json!(["mcp"])); assert_eq!( - plugin_hooks(PluginHost::ClaudeCode)["hooks"]["SessionStart"][0]["hooks"][0]["command"], - json!("nemo-relay plugin-shim hook claude") + server["env"], + json!({ + "NEMO_RELAY_GATEWAY_BIND": "127.0.0.1:47632", + "NEMO_RELAY_MCP_GENERATION_FILE": &generation_fence + }) ); -} - -#[test] -fn plugin_setup_delegates_and_dry_run_skips_runner_calls() { - let dir = tempdir().unwrap(); - let setup_runner = MockSetupRunner::default(); - let dry_run = PluginInstallOptions { + 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")) + ); + assert!( + plugin_mcp_config( + PluginHost::ClaudeCode, + Path::new("/bin/nemo-relay"), + &generation_fence, + ) + .unwrap() + .is_none() + ); + assert_eq!( + plugin_hooks(PluginHost::Codex, Path::new("/bin/nemo-relay"))["hooks"]["SessionStart"][0]["hooks"] + [0]["command"], + json!(crate::plugin_shim::codex_plugin_hook_command(Path::new( + "/bin/nemo-relay" + ))) + ); + assert_eq!( + plugin_hooks(PluginHost::ClaudeCode, Path::new("/bin/nemo-relay"))["hooks"]["SessionStart"] + [0]["hooks"][0]["command"], + json!("nemo-relay plugin-shim hook claude") + ); +} + +#[test] +fn relay_identity_uses_running_executable_when_path_points_elsewhere() { + 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(); + + assert_eq!(relay, PathBuf::from("/opt/nemo-relay/current/nemo-relay")); + assert_eq!( + plugin_hooks(PluginHost::Codex, &relay)["hooks"]["SessionStart"][0]["hooks"][0]["command"], + json!(crate::plugin_shim::codex_plugin_hook_command(&relay)) + ); + assert_eq!( + plugin_mcp_config( + PluginHost::Codex, + &relay, + Path::new("/plugins/nemo-relay-plugin/.nemo-relay-generation"), + ) + .unwrap() + .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 = plugin_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_FILE", + "NEMO_RELAY_FAIL_CLOSED", + "NEMO_RELAY_TEST_CODEX_LOG", + "NEMO_RELAY_Test_CodeX_Log", + ] + .map(str::to_string), + Some(&config), + ); + + assert!(names.is_sorted()); + for expected in [ + "OPENAI_API_KEY", + "ANTHROPIC_API_KEY", + "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}" + ); + } + for excluded in [ + "NEMO_RELAY_WORKER_TOKEN", + "NEMO_RELAY_PLUGIN_BINARY", + "NEMO_RELAY_Plugin_Binary", + "NEMO_RELAY_GATEWAY_BIND", + "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 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")); + + 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(PluginHost::Codex, dir.path()); + 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(); + run_plugin_setup(PluginHost::Codex, &layout, &dry_run, &setup_runner).unwrap(); + run_plugin_uninstall( + PluginHost::ClaudeCode, + &layout.plugin_root, + &dry_run, + &setup_runner, + ) + .unwrap(); + run_plugin_doctor( + PluginHost::Codex, + &layout.plugin_root, + &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(); + run_plugin_setup(PluginHost::Codex, &layout, &normal, &setup_runner).unwrap(); + run_plugin_uninstall( + PluginHost::ClaudeCode, + &layout.plugin_root, + &normal, + &setup_runner, + ) + .unwrap(); + run_plugin_doctor( + PluginHost::Codex, + &layout.plugin_root, + &normal, + &setup_runner, + ) + .unwrap(); + let report = + run_plugin_doctor_json(PluginHost::ClaudeCode, &layout.plugin_root, &setup_runner).unwrap(); assert_eq!( setup_runner.calls(), @@ -382,45 +1264,29 @@ fn plugin_setup_delegates_and_dry_run_skips_runner_calls() { } #[test] -fn real_plugin_setup_runner_uses_temp_home_for_codex_and_claude_paths() { +fn real_plugin_setup_runner_uses_temp_home_for_claude_paths() { let dir = tempdir().unwrap(); let _home = HomeScope::enter(dir.path()); let runner = RealPluginSetupRunner; + let plugin_root = dir.path().join("plugin"); runner - .setup(PluginHost::Codex, DEFAULT_GATEWAY_URL) + .setup(PluginHost::ClaudeCode, DEFAULT_GATEWAY_URL, &plugin_root) .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) + .doctor(PluginHost::ClaudeCode, DEFAULT_GATEWAY_URL, &plugin_root) .is_ok() ); let claude_report = runner - .doctor_json(PluginHost::ClaudeCode, DEFAULT_GATEWAY_URL) + .doctor_json(PluginHost::ClaudeCode, DEFAULT_GATEWAY_URL, &plugin_root) .unwrap(); assert_eq!( claude_report["checks"]["claude_provider_routing"], json!(true) ); runner - .uninstall(PluginHost::ClaudeCode, DEFAULT_GATEWAY_URL) + .uninstall(PluginHost::ClaudeCode, DEFAULT_GATEWAY_URL, &plugin_root) .unwrap(); } @@ -428,15 +1294,15 @@ fn real_plugin_setup_runner_uses_temp_home_for_codex_and_claude_paths() { 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" + "configure Codex provider and trust plugin-owned hooks" ); assert_eq!( setup_action_description(PluginHost::Codex, "restore"), - "restore Codex provider and generated hook configuration" + "remove Codex provider and plugin hook trust" ); assert_eq!( setup_action_description(PluginHost::Codex, "doctor"), - "check Codex provider and generated hooks" + "check Codex provider and plugin-owned hooks" ); assert_eq!( setup_action_description(PluginHost::ClaudeCode, "configure"), @@ -467,6 +1333,7 @@ fn host_command_helpers_cover_dry_run_missing_failure_and_reporting() { ); require_host_cli(PluginHost::Codex, &dry_run, &runner).unwrap(); validate_relay_plugin_shim(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()], @@ -521,6 +1388,12 @@ fn host_command_helpers_cover_dry_run_missing_failure_and_reporting() { .unwrap_err() .contains("plugin-shim hook") ); + 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( @@ -693,7 +1566,7 @@ 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 _path = PathScope::set_isolated(&empty_path, &dir.path().join("home")); let install_error = install(crate::config::InstallCommand { host: PluginHost::All, @@ -756,18 +1629,6 @@ fn top_level_install_uninstall_and_doctor_report_empty_host_selection() { "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(); @@ -833,9 +1694,11 @@ fn install_codex_generates_marketplace_and_runs_setup() { .unwrap(); let layout = PluginLayout::new(PluginHost::Codex, dir.path()); - assert!( - !layout.hooks_path.exists(), - "generated Codex marketplace must not also install plugin hook templates" + 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(PluginHost::Codex, Path::new("/bin/nemo-relay")) ); assert_eq!( runner.commands(), @@ -847,7 +1710,21 @@ fn install_codex_generates_marketplace_and_runs_setup() { "/bin/codex plugin add nemo-relay-plugin@nemo-relay-local".into(), ] ); - assert_eq!(runner.quiet_commands(), vec![relay_validation_command()]); + 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( + PluginHost::Codex, + Path::new("/bin/nemo-relay"), + &layout.generation_fence, + ) + .unwrap() + .unwrap() + ); assert_eq!( setup_runner.calls(), vec![format!("setup codex {DEFAULT_GATEWAY_URL}")] @@ -878,67 +1755,660 @@ fn install_prunes_stale_managed_plugin_root() { 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(PluginHost::Codex, dir.path()); + let layout = PluginLayout::new(PluginHost::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( + PluginHost::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(PluginHost::Codex, dir.path()); + let layout = PluginLayout::new(PluginHost::Codex, dir.path()); + std::fs::remove_file(&layout.generation_fence).unwrap(); + let state = std::fs::read(&layout.state_path).unwrap(); + + let error = install_host( + PluginHost::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( + PluginHost::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(PluginHost::Codex, dir.path()); + let layout = PluginLayout::new(PluginHost::Codex, dir.path()); + std::fs::write(&layout.generation_fence, b"").unwrap(); + let state = std::fs::read(&layout.state_path).unwrap(); + + let error = install_host( + PluginHost::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_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(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 == "snapshot codex") + ); + assert!( + setup_runner + .calls() + .iter() + .all(|call| call != &format!("uninstall codex {DEFAULT_GATEWAY_URL}")) + ); + assert!( + setup_runner + .calls() + .iter() + .any(|call| call == "refresh codex") + ); + let setup_calls = setup_runner.calls(); + let refresh_index = setup_calls + .iter() + .position(|call| call == "refresh codex") + .unwrap(); + let snapshot_index = setup_calls + .iter() + .position(|call| call == "snapshot codex") + .unwrap(); + assert!(snapshot_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(PluginHost::Codex, dir.path()); + let layout = PluginLayout::new(PluginHost::Codex, dir.path()); + let previous = InstallGeneration::capture(layout.generation_fence.clone()).unwrap(); + + install_host(PluginHost::Codex, &options, &runner, &setup_runner).unwrap(); + + let error = previous.verify_current().unwrap_err(); + assert!(error.contains("has been retired")); + InstallGeneration::capture(layout.generation_fence.clone()).unwrap(); + 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) + ); +} + +#[test] +fn force_install_replaces_a_relocated_fenced_install_without_old_residue() { + 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 previous = InstallGeneration::capture(relocated.generation_fence.clone()).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) + }; + + install_host(PluginHost::Codex, &install_options, &runner, &setup_runner).unwrap(); + + let current = PluginLayout::new(PluginHost::Codex, &selected_dir); + assert!(current.marketplace_root.exists()); + assert!(!relocated.marketplace_root.exists()); + assert!(!sentinel.exists()); + assert!(previous.verify_current().unwrap_err().contains("retired")); + let state = read_state(PluginHost::Codex, &selected_dir).unwrap(); + assert_eq!(state.marketplace_root, current.marketplace_root); + assert_eq!(state.plugin_root, current.plugin_root); + assert_no_force_replacement_residue(&selected_dir); + assert_no_force_replacement_residue(&relocated_dir); +} + +#[test] +fn force_install_rollback_restores_a_relocated_fenced_install_and_registration() { + 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(PluginHost::Codex, &selected_dir)).unwrap(); + let previous = InstallGeneration::capture(relocated.generation_fence.clone()).unwrap(); + let runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("codex", "/bin/codex") + .with_codex_registration_sequence(&[(true, true), (false, false), (false, false)]); + let setup_runner = MockSetupRunner { + failing_call: Some(format!("doctor codex {DEFAULT_GATEWAY_URL}")), + ..MockSetupRunner::default() + }; + let install_options = PluginInstallOptions { + force: true, + skip_doctor: false, + ..options(&selected_dir) + }; + + let error = + install_host(PluginHost::Codex, &install_options, &runner, &setup_runner).unwrap_err(); + + assert!(error.contains("doctor codex"), "{error}"); + let current = PluginLayout::new(PluginHost::Codex, &selected_dir); + assert!(!current.marketplace_root.exists()); + assert!(relocated.marketplace_root.exists()); + assert_eq!( + std::fs::read_to_string(&sentinel).unwrap(), + "restore-exactly" + ); + previous.verify_current().unwrap(); + assert_eq!( + std::fs::read(state_path(PluginHost::Codex, &selected_dir)).unwrap(), + original_state + ); + let marketplace_adds = runner + .commands() + .into_iter() + .filter(|command| command.contains("plugin marketplace add")) + .collect::>(); + assert_eq!( + marketplace_adds.last(), + Some(&format!( + "/bin/codex plugin marketplace add {}", + relocated.marketplace_root.display() + )) + ); + assert!( + setup_runner + .calls() + .iter() + .any(|call| call == "restore snapshot") + ); + 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(PluginHost::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(PluginHost::Codex, dir.path()); + let layout = PluginLayout::new(PluginHost::Codex, dir.path()); + std::fs::remove_file(&layout.generation_fence).unwrap(); + + let error = install_host(PluginHost::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 open"), + ] { + 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(PluginHost::Codex, dir.path()); + let layout = PluginLayout::new(PluginHost::Codex, dir.path()); + corrupt_generation_fence(&layout.generation_fence, corruption); + + let error = install_host(PluginHost::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(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); + let layout = PluginLayout::new(PluginHost::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(PluginHost::Codex, dir.path()); + + install_host(PluginHost::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(PluginHost::Codex, dir.path()); + 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, + ) + .unwrap(); + + install_host(PluginHost::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(); + + 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 codex".into()), + ..MockSetupRunner::default() + }; + let options = PluginInstallOptions { + force: true, + ..options(dir.path()) + }; + write_installed_state(PluginHost::Codex, dir.path()); + let layout = PluginLayout::new(PluginHost::Codex, dir.path()); + let previous = InstallGeneration::capture(layout.generation_fence.clone()).unwrap(); + + let error = install_host(PluginHost::Codex, &options, &runner, &setup_runner).unwrap_err(); + + assert!(error.contains("refresh codex 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(), + "refresh codex".to_string(), + "restore snapshot".to_string(), + ] + ); +} + +#[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(PluginHost::Codex, dir.path()); + let layout = PluginLayout::new(PluginHost::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(PluginHost::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 == &format!("uninstall codex {DEFAULT_GATEWAY_URL}")) + .any(|call| call == "restore snapshot") + ); + let setup_calls = setup_runner.calls(); + let refreshes = setup_calls + .iter() + .enumerate() + .filter(|(_, call)| call.as_str() == "refresh codex") + .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_without_state_unregisters_host_before_reinstall() { +fn force_install_does_not_uninstall_restored_setup_after_setup_failure() { 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(); + .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(PluginHost::Codex, dir.path()); + let layout = PluginLayout::new(PluginHost::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(); - install_host(PluginHost::Codex, &options, &runner, &setup_runner).unwrap(); + let error = install_host(PluginHost::Codex, &options, &runner, &setup_runner).unwrap_err(); - 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!(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!( + setup_runner + .calls() + .iter() + .all(|call| call != &format!("uninstall codex {DEFAULT_GATEWAY_URL}")) + ); } #[test] @@ -1022,6 +2492,31 @@ fn unsupported_relay_path_fails_before_generating_plugin() { ); } +#[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( + PluginHost::Codex, + &options(dir.path()), + &runner, + &setup_runner, + ) + .unwrap_err(); + + assert!(error.contains("native `nemo-relay mcp` support")); + assert!( + !PluginLayout::new(PluginHost::Codex, dir.path()) + .marketplace_root + .exists() + ); +} + #[test] fn setup_failure_rolls_back_generated_files_and_registration() { let dir = tempdir().unwrap(); @@ -1153,7 +2648,7 @@ fn plugin_registration_failure_rolls_back_marketplace_without_plugin_removal() { } #[test] -fn state_write_failure_removes_generated_marketplace() { +fn invalid_existing_state_fails_before_generating_marketplace() { let dir = tempdir().unwrap(); let runner = MockRunner::default() .with_executable("nemo-relay", "/bin/nemo-relay") @@ -1170,7 +2665,7 @@ fn state_write_failure_removes_generated_marketplace() { ) .unwrap_err(); - assert!(error.contains("failed to write")); + assert!(error.contains("failed to snapshot"), "{error}"); assert!(!layout.marketplace_root.exists()); assert!(layout.state_path.exists()); assert!(runner.commands().is_empty()); @@ -1214,14 +2709,15 @@ fn retry_after_partial_registration_rollback_does_not_restore_uninstalled_setup( ) .unwrap(); - assert!( - setup_runner.calls().is_empty(), - "retry cleanup must not restore provider/hooks setup that install never reached" + assert_eq!( + setup_runner.calls(), + vec!["refresh codex"], + "retry cleanup may stop the gateway but must not restore provider/hooks setup that install never reached" ); } #[test] -fn retry_after_setup_attempted_rollback_restores_setup() { +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") @@ -1244,7 +2740,7 @@ fn retry_after_setup_attempted_rollback_restores_setup() { let state = read_state(PluginHost::Codex, dir.path()).unwrap(); assert!(state.host_plugin_removed); assert!(!state.host_marketplace_removed); - assert!(state.plugin_setup_installed); + assert!(!state.plugin_setup_installed); let runner = MockRunner::default() .with_executable("nemo-relay", "/bin/nemo-relay") @@ -1261,7 +2757,7 @@ fn retry_after_setup_attempted_rollback_restores_setup() { setup_runner .calls() .iter() - .any(|call| call == &format!("uninstall codex {DEFAULT_GATEWAY_URL}")) + .all(|call| call != &format!("uninstall codex {DEFAULT_GATEWAY_URL}")) ); } @@ -1292,12 +2788,111 @@ fn uninstall_uses_installed_state_and_removes_marketplace() { 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 codex") + .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(PluginHost::Codex, dir.path()); + let layout = PluginLayout::new(PluginHost::Codex, dir.path()); + std::fs::remove_file(&layout.generation_fence).unwrap(); + + let error = uninstall_host( + PluginHost::Codex, + &options(dir.path()), + &runner, + &setup_runner, + ) + .unwrap_err(); + assert!( - setup_runner - .calls() - .iter() - .any(|call| call == &format!("uninstall codex {DEFAULT_GATEWAY_URL}")) + 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(PluginHost::Codex, dir.path()); + let layout = PluginLayout::new(PluginHost::Codex, dir.path()); + std::fs::remove_file(&layout.generation_fence).unwrap(); + + let error = uninstall_host( + PluginHost::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 open"), + ] { + 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(PluginHost::Codex, dir.path()); + let layout = PluginLayout::new(PluginHost::Codex, dir.path()); + corrupt_generation_fence(&layout.generation_fence, corruption); + + let error = uninstall_host( + PluginHost::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] @@ -1358,12 +2953,92 @@ fn doctor_json_uses_quiet_plugin_report() { 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(PluginHost::Codex, dir.path()); + let layout = PluginLayout::new(PluginHost::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( + PluginHost::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(PluginHost::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(PluginHost::Codex, dir.path()); + + let report = + doctor_host_json_value(PluginHost::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 Codex to codex-cli 0.143.0 or newer, then run `nemo-relay install codex --force`" + ) + ); + + let text_error = + doctor_host(PluginHost::Codex, &options, &runner, &setup_runner).unwrap_err(); + assert!(text_error.contains("remediation: upgrade Codex")); + assert!(text_error.contains("codex-cli 0.143.0 or newer")); + } +} + #[test] fn readiness_report_marks_missing_generated_plugin_files_as_failed() { let dir = tempdir().unwrap(); @@ -1396,6 +3071,109 @@ fn readiness_report_marks_missing_generated_plugin_files_as_failed() { ); } +#[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(PluginHost::Codex, dir.path()); + let layout = PluginLayout::new(PluginHost::Codex, dir.path()); + std::fs::remove_file(layout.mcp_config).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 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(PluginHost::Codex, dir.path()); + let layout = PluginLayout::new(PluginHost::Codex, dir.path()); + std::fs::remove_file(layout.generation_fence).unwrap(); + + let report = collect_host_plugin_readiness(PluginHost::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 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(PluginHost::Codex, dir.path()); + let layout = PluginLayout::new(PluginHost::Codex, dir.path()); + write_json( + &layout.mcp_config, + &plugin_mcp_config( + PluginHost::Codex, + Path::new("/tmp/other-relay"), + &layout.generation_fence, + ) + .unwrap() + .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 MCP server" && !check.ok && check.details.contains("unexpected") + })); +} + +#[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(PluginHost::Codex, dir.path()); + let layout = PluginLayout::new(PluginHost::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(PluginHost::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(); @@ -1651,7 +3429,7 @@ fn doctor_fails_when_claude_host_marketplace_is_missing() { } #[test] -fn uninstall_host_failure_does_not_restore_plugin_setup() { +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") @@ -1668,14 +3446,17 @@ fn uninstall_host_failure_does_not_restore_plugin_setup() { .unwrap_err(); assert!(error.contains("plugin remove")); - assert!( - setup_runner.calls().is_empty(), - "provider/hook setup should not be restored until host unregister succeeds" + assert_eq!( + setup_runner.calls(), + vec![ + "refresh codex".to_string(), + format!("uninstall codex {DEFAULT_GATEWAY_URL}"), + ] ); } #[test] -fn uninstall_records_host_removal_phases_before_plugin_restore() { +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") @@ -1696,8 +3477,9 @@ fn uninstall_records_host_removal_phases_before_plugin_restore() { assert!(error.contains("uninstall codex")); let state = read_state(PluginHost::Codex, dir.path()).unwrap(); - assert!(state.host_plugin_removed); - assert!(state.host_marketplace_removed); + assert!(!state.host_plugin_removed); + assert!(!state.host_marketplace_removed); + assert!(state.plugin_setup_installed); } #[test] @@ -1720,6 +3502,7 @@ fn uninstall_retry_skips_host_removal_after_prior_success() { &options(dir.path()), ) .unwrap(); + crate::install_generation::write_new_generation(&layout.generation_fence).unwrap(); uninstall_host( PluginHost::Codex, @@ -1754,6 +3537,7 @@ fn uninstall_retry_skips_plugin_removal_after_marketplace_failure() { let setup_runner = MockSetupRunner::default(); let layout = PluginLayout::new(PluginHost::Codex, dir.path()); write_state(&layout, &options(dir.path())).unwrap(); + crate::install_generation::write_new_generation(&layout.generation_fence).unwrap(); let error = uninstall_host( PluginHost::Codex, diff --git a/crates/cli/tests/coverage/plugin_shim_tests.rs b/crates/cli/tests/coverage/plugin_shim_tests.rs index 4ebbcbe19..17744c373 100644 --- a/crates/cli/tests/coverage/plugin_shim_tests.rs +++ b/crates/cli/tests/coverage/plugin_shim_tests.rs @@ -1,18 +1,283 @@ // SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +use std::collections::{BTreeSet, VecDeque}; 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, Instant}; use serde_json::{Value, json}; use tempfile::tempdir; +use toml_edit::{DocumentMut, Item}; use super::*; +#[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 { + expected_plugin_hook_command().unwrap() +} + +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(); + fs::write( + &path, + serde_json::to_vec_pretty(&generated_hooks( + CodingAgent::Codex, + &expected_plugin_command(), + )) + .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() + }; + 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(), + )) + .unwrap(), + ) + .unwrap(); + } + CodexHookMetadata { + key: key.into(), + event_name: event_name.into(), + handler_type: "command".into(), + command: Some(expected_plugin_command()), + 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))) @@ -68,6 +333,7 @@ struct HomeScope<'a> { _guard: std::sync::MutexGuard<'a, ()>, prev_home: Option, prev_userprofile: Option, + prev_codex_home: Option, } impl<'a> HomeScope<'a> { @@ -77,15 +343,18 @@ impl<'a> HomeScope<'a> { .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"); // 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"); } Self { _guard: guard, prev_home, prev_userprofile, + prev_codex_home, } } } @@ -102,6 +371,10 @@ impl<'a> Drop for HomeScope<'a> { 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"), + } } } } @@ -160,14 +433,19 @@ fn hook_with_io_defaults_blank_payload_and_writes_non_empty_response() { let seen_payload = std::cell::RefCell::new(Vec::new()); let status = hook_with_io( - CodingAgent::Codex, - Some("http://127.0.0.1:59999"), + HookInvocation { + agent: CodingAgent::Codex, + gateway_url: Some("http://127.0.0.1:59999"), + max_payload_bytes: DEFAULT_HOOK_STDIN_BYTES, + preflight_gateway: false, + }, &mut input, &mut output, |agent, url| { ensured .borrow_mut() .push((agent.as_arg().to_string(), url.to_string())); + Ok(()) }, |agent, url, payload| { assert_eq!(agent, CodingAgent::Codex); @@ -182,10 +460,188 @@ fn hook_with_io_defaults_blank_payload_and_writes_non_empty_response() { 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())] - ); + assert!(ensured.into_inner().is_empty()); +} + +#[test] +fn hook_payload_reader_rejects_bytes_beyond_its_limit() { + let mut input = std::io::Cursor::new(b"12345".to_vec()); + let mut payload = Vec::new(); + + let error = read_hook_payload(&mut input, &mut payload, 4).unwrap_err(); + + assert!(error.contains("exceeds the 4-byte limit")); + assert_eq!(payload, b"12345"); +} + +#[test] +fn hook_with_io_applies_fail_open_and_fail_closed_to_oversized_stdin() { + for fail_closed in [false, true] { + let mut input = std::io::Cursor::new(b"12345".to_vec()); + let mut output = Vec::new(); + let result = hook_with_io( + HookInvocation { + agent: CodingAgent::Codex, + gateway_url: Some(DEFAULT_URL), + max_payload_bytes: 4, + preflight_gateway: true, + }, + &mut input, + &mut output, + |_agent, _url| panic!("oversized input must not bootstrap the gateway"), + |_agent, _url, _payload| panic!("oversized input must not be forwarded"), + || fail_closed, + ); + + if fail_closed { + assert!(result.unwrap_err().contains("exceeds the 4-byte limit")); + } else { + assert_eq!(result.unwrap(), ExitCode::SUCCESS); + } + assert!(output.is_empty()); + } +} + +#[test] +fn hook_with_io_applies_fail_open_and_fail_closed_to_stdin_read_errors() { + struct FailingReader; + + impl Read for FailingReader { + fn read(&mut self, _buffer: &mut [u8]) -> std::io::Result { + Err(std::io::Error::other("synthetic stdin failure")) + } + } + + for fail_closed in [false, true] { + let mut input = FailingReader; + let mut output = Vec::new(); + let result = hook_with_io( + HookInvocation { + agent: CodingAgent::Codex, + gateway_url: Some(DEFAULT_URL), + max_payload_bytes: DEFAULT_HOOK_STDIN_BYTES, + preflight_gateway: true, + }, + &mut input, + &mut output, + |_agent, _url| panic!("unreadable input must not bootstrap the gateway"), + |_agent, _url, _payload| panic!("unreadable input must not be forwarded"), + || fail_closed, + ); + + if fail_closed { + let error = result.unwrap_err(); + assert!(error.contains("failed to read hook payload")); + assert!(error.contains("synthetic stdin failure")); + } else { + assert_eq!(result.unwrap(), ExitCode::SUCCESS); + } + assert!(output.is_empty()); + } +} + +#[test] +fn hook_with_io_bootstraps_and_retries_once_only_for_pre_send_connection_failure() { + let mut input = std::io::Cursor::new(br#"{"event":"prompt"}"#.to_vec()); + let mut output = Vec::new(); + let ensures = std::cell::Cell::new(0); + let posts = std::cell::Cell::new(0); + + let status = hook_with_io( + HookInvocation { + agent: CodingAgent::Codex, + gateway_url: Some("http://127.0.0.1:59997"), + max_payload_bytes: DEFAULT_HOOK_STDIN_BYTES, + preflight_gateway: false, + }, + &mut input, + &mut output, + |_agent, _url| { + ensures.set(ensures.get() + 1); + Ok(()) + }, + |_agent, _url, _payload| { + posts.set(posts.get() + 1); + if posts.get() == 1 { + Err(HookForwardError::retryable( + "connection refused before send", + )) + } else { + Ok(b"retried".to_vec()) + } + }, + || true, + ) + .unwrap(); + + assert_eq!(status, ExitCode::SUCCESS); + assert_eq!(output, b"retried"); + assert_eq!(ensures.get(), 1); + assert_eq!(posts.get(), 2); +} + +#[test] +fn hook_with_io_does_not_bootstrap_or_retry_ambiguous_forward_failure() { + let mut input = std::io::Cursor::new(b"{}".to_vec()); + let mut output = Vec::new(); + let ensures = std::cell::Cell::new(0); + let posts = std::cell::Cell::new(0); + + let error = hook_with_io( + HookInvocation { + agent: CodingAgent::Codex, + gateway_url: Some(DEFAULT_URL), + max_payload_bytes: DEFAULT_HOOK_STDIN_BYTES, + preflight_gateway: false, + }, + &mut input, + &mut output, + |_agent, _url| { + ensures.set(ensures.get() + 1); + Ok(()) + }, + |_agent, _url, _payload| { + posts.set(posts.get() + 1); + Err(HookForwardError::not_retryable("response read failed")) + }, + || true, + ) + .unwrap_err(); + + assert!(error.contains("response read failed")); + assert_eq!(ensures.get(), 0); + assert_eq!(posts.get(), 1); +} + +#[test] +fn hook_with_io_propagates_bootstrap_error_without_second_post() { + let mut input = std::io::Cursor::new(b"{}".to_vec()); + let mut output = Vec::new(); + let posts = std::cell::Cell::new(0); + + let error = hook_with_io( + HookInvocation { + agent: CodingAgent::Codex, + gateway_url: Some(DEFAULT_URL), + max_payload_bytes: DEFAULT_HOOK_STDIN_BYTES, + preflight_gateway: false, + }, + &mut input, + &mut output, + |_agent, _url| Err("startup log says bind failed".into()), + |_agent, _url, _payload| { + posts.set(posts.get() + 1); + Err(HookForwardError::retryable( + "connection refused before send", + )) + }, + || true, + ) + .unwrap_err(); + + assert!(error.contains("sidecar bootstrap failed")); + assert!(error.contains("startup log says bind failed")); + assert_eq!(posts.get(), 1); } #[test] @@ -193,12 +649,16 @@ 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"), + HookInvocation { + agent: CodingAgent::ClaudeCode, + gateway_url: Some("http://127.0.0.1:59998"), + max_payload_bytes: DEFAULT_HOOK_STDIN_BYTES, + preflight_gateway: false, + }, &mut input, &mut output, - |_agent, _url| {}, - |_agent, _url, _payload| Err("forward failed open".to_string()), + |_agent, _url| Ok(()), + |_agent, _url, _payload| Err(HookForwardError::not_retryable("forward failed open")), || false, ) .unwrap(); @@ -209,12 +669,16 @@ 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 error = hook_with_io( - CodingAgent::ClaudeCode, - Some("http://127.0.0.1:59998"), + HookInvocation { + agent: CodingAgent::ClaudeCode, + gateway_url: Some("http://127.0.0.1:59998"), + max_payload_bytes: DEFAULT_HOOK_STDIN_BYTES, + preflight_gateway: false, + }, &mut input, &mut output, - |_agent, _url| {}, - |_agent, _url, _payload| Err("forward failed closed".to_string()), + |_agent, _url| Ok(()), + |_agent, _url, _payload| Err(HookForwardError::not_retryable("forward failed closed")), || true, ) .unwrap_err(); @@ -223,6 +687,75 @@ fn hook_with_io_applies_fail_open_and_fail_closed_forwarding_policies() { assert!(output.is_empty()); } +#[test] +fn codex_hook_preflight_rejects_healthy_wrong_fingerprint_under_both_policies() { + let dir = tempdir().unwrap(); + let _home = HomeScope::enter(dir.path()); + let _config = EnvVarGuard::set_path("XDG_CONFIG_HOME", &dir.path().join("config")); + let _runtime = EnvVarGuard::set_path("XDG_RUNTIME_DIR", &dir.path().join("runtime")); + + for fail_closed in [false, true] { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let server = thread::spawn(move || { + for _ in 0..2 { + let (mut stream, _) = listener.accept().unwrap(); + let request = read_http_request(&mut stream); + let request = String::from_utf8_lossy(&request); + assert!(request.starts_with("GET /healthz")); + assert!( + request.contains("X-NeMo-Relay-Bootstrap-Fingerprint: expected-fingerprint") + ); + let body = format!( + r#"{{"status":"incompatible","service":"nemo-relay","version":"{}","bootstrap_protocol":1}}"#, + env!("CARGO_PKG_VERSION") + ); + stream + .write_all( + format!( + "HTTP/1.1 409 Conflict\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ) + .as_bytes(), + ) + .unwrap(); + } + }); + let url = format!("http://{address}"); + let mut input = std::io::Cursor::new(b"{}".to_vec()); + let mut output = Vec::new(); + let result = hook_with_io( + HookInvocation { + agent: CodingAgent::Codex, + gateway_url: Some(&url), + max_payload_bytes: DEFAULT_HOOK_STDIN_BYTES, + preflight_gateway: true, + }, + &mut input, + &mut output, + |_agent, _url| { + GatewaySpec::new(CodingAgent::Codex, address) + .with_fingerprint("expected-fingerprint") + .ensure() + .map(|_| ()) + }, + |_agent, _url, _payload| panic!("wrong-fingerprint gateway must not receive the hook"), + || fail_closed, + ); + if fail_closed { + assert!( + result + .unwrap_err() + .contains("gateway identity preflight failed") + ); + } else { + assert_eq!(result.unwrap(), ExitCode::SUCCESS); + } + assert!(output.is_empty()); + server.join().unwrap(); + } +} + #[test] fn backup_preserves_first_snapshot() { let dir = tempdir().unwrap(); @@ -250,6 +783,636 @@ fn atomic_write_replaces_existing_destination() { 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 hooks = required_codex_hook_metadata(&reported_hooks_path, "untrusted", true); + 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(); @@ -373,10 +1536,9 @@ supports_websockets = false #[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"); + let plugin_root = dir.path().join("plugin"); + let path = plugin_root.join("hooks").join("hooks.json"); + fs::create_dir_all(path.parent().unwrap()).unwrap(); fs::write( &path, serde_json::to_vec_pretty(&json!({ @@ -398,20 +1560,24 @@ fn codex_hooks_installed_requires_generated_plugin_local_groups() { ) .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()); + assert!(!codex_hooks_installed(&path).unwrap()); + write_plugin_hooks(&plugin_root); + assert!(codex_hooks_installed(&path).unwrap()); } +#[cfg(not(windows))] #[test] -fn codex_doctor_allows_stopped_lazy_sidecar_when_static_setup_is_valid() { +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(); - install_codex_hooks(&codex_dir.join("hooks.json"), 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); let status = doctor(PluginShimDoctorCommand { agent: CodingAgent::Codex, @@ -420,10 +1586,20 @@ fn codex_doctor_allows_stopped_lazy_sidecar_when_static_setup_is_valid() { .unwrap(); assert_eq!(status, std::process::ExitCode::SUCCESS); + 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_doctor_requires_enabled_hooks_feature() { +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"); @@ -445,15 +1621,11 @@ 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(); + let plugin_root = dir.path().join("plugin"); + let hooks_path = write_plugin_hooks(&plugin_root); - assert_eq!(status, std::process::ExitCode::FAILURE); + assert!(!codex_provider_installed(DEFAULT_URL)); + assert!(codex_hooks_installed(&hooks_path).unwrap()); } #[test] @@ -487,66 +1659,72 @@ fn plugin_shim_helpers_reject_unsupported_agents_and_report_lazy_claude_status() .contains("supports claude") ); assert!( - doctor_plugin(CodingAgent::Hermes, DEFAULT_URL) + doctor_plugin(CodingAgent::Hermes, DEFAULT_URL, dir.path()) .unwrap_err() .contains("supports claude and codex") ); assert!( - doctor_plugin_json(CodingAgent::Hermes, DEFAULT_URL) + 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).unwrap(); + 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_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() { +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(); - 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(); + 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"); - 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 - ); + assert!(!hooks_path.exists()); + assert!(codex_provider_installed(DEFAULT_URL)); - 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 - )); + 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] @@ -591,6 +1769,257 @@ supports_websockets = false 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(); @@ -755,13 +2184,11 @@ 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!({ + fs::write( + &hooks_path, + serde_json::to_vec_pretty(&json!({ + "hooks": {"SessionStart": [{ "hooks": [ { "type": "command", @@ -769,10 +2196,19 @@ hooks = false "timeout": 30 } ] - })); - fs::write(&hooks_path, serde_json::to_vec_pretty(&hooks).unwrap()).unwrap(); + }]}})) + .unwrap(), + ) + .unwrap(); + install_codex_with_trust( + DEFAULT_URL, + &expected_plugin_command(), + |_home, _config, _command| Ok(()), + ) + .unwrap(); - uninstall_codex(DEFAULT_URL).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")); @@ -1063,18 +2499,322 @@ fn claude_reinstall_uses_fresh_backup_after_prior_restore() { } #[test] -fn stale_lock_is_repaired_after_grace_period_even_when_pid_file_exists() { +fn advisory_sidecar_lock_blocks_until_the_owner_releases_it() { 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(), + let url = "http://127.0.0.1:47632"; + let owner = lock_sidecar_endpoint(dir.path(), url).unwrap(); + let path = dir.path().to_path_buf(); + let (ready_sender, ready_receiver) = std::sync::mpsc::channel(); + let (acquired_sender, acquired_receiver) = std::sync::mpsc::channel(); + let contender = thread::spawn(move || { + ready_sender.send(()).unwrap(); + let lock = lock_sidecar_endpoint(&path, url).unwrap(); + acquired_sender.send(()).unwrap(); + drop(lock); + }); + + ready_receiver.recv_timeout(Duration::from_secs(5)).unwrap(); + let error = lock_sidecar_endpoint_for(dir.path(), url, Duration::ZERO).unwrap_err(); + assert!(error.contains("timed out waiting for sidecar lock")); + drop(owner); + acquired_receiver + .recv_timeout(Duration::from_secs(5)) + .unwrap(); + contender.join().unwrap(); +} + +#[test] +fn sidecar_lock_zero_wait_fails_when_the_endpoint_is_owned() { + let dir = tempdir().unwrap(); + let url = "http://127.0.0.1:47632"; + let owner = lock_sidecar_endpoint(dir.path(), url).unwrap(); + + let error = lock_sidecar_endpoint_for(dir.path(), url, Duration::ZERO).unwrap_err(); + + assert!(error.contains("timed out waiting for sidecar lock")); + drop(owner); +} + +#[test] +fn sidecar_ownership_records_are_endpoint_specific_and_enumerated() { + let dir = tempdir().unwrap(); + let first = sidecar_owner_path(dir.path(), CodingAgent::Codex, "http://127.0.0.1:47632"); + let second = sidecar_owner_path(dir.path(), CodingAgent::Codex, "http://127.0.0.1:47633"); + let legacy = dir.path().join("codex-sidecar.owner.json"); + let ignored = sidecar_owner_path( + dir.path(), + CodingAgent::ClaudeCode, + "http://127.0.0.1:47634", + ); + for path in [&first, &second, &legacy, &ignored] { + fs::write(path, "{}").unwrap(); + } + + let paths = sidecar_owner_paths(dir.path(), CodingAgent::Codex).unwrap(); + + assert_eq!(paths.len(), 3); + assert!(paths.contains(&first)); + assert!(paths.contains(&second)); + assert!(paths.contains(&legacy)); + assert_ne!(first, second); +} + +#[test] +fn managed_sidecar_publishes_valid_ownership_before_parent_validation() { + let dir = tempdir().unwrap(); + let _home = HomeScope::enter(dir.path()); + let state = dir.path().join("bootstrap-state"); + let _state = EnvVarGuard::set_path(BOOTSTRAP_STATE_DIR_ENV, &state); + let _agent = EnvVarGuard::set_value(BOOTSTRAP_AGENT_ENV, "codex"); + let _token = + EnvVarGuard::set_value("NEMO_RELAY_BOOTSTRAP_SHUTDOWN_TOKEN", "test-shutdown-token"); + let _fingerprint = + EnvVarGuard::set_value(crate::config::BOOTSTRAP_FINGERPRINT_ENV, "test-fingerprint"); + let address = "127.0.0.1:47632".parse().unwrap(); + + publish_sidecar_owner_from_env(address).unwrap(); + + let url = format!("http://{address}"); + let owner_path = sidecar_owner_path(&state, CodingAgent::Codex, &url); + let pid_path = sidecar_pid_path(&state, CodingAgent::Codex, &url); + validate_sidecar_owner( + &owner_path, + &pid_path, + std::process::id(), + &url, + "test-shutdown-token", + Some("test-fingerprint"), + ) + .unwrap(); +} + +#[test] +fn managed_shutdown_rejects_a_listener_without_authenticated_health_proof() { + let dir = tempdir().unwrap(); + let _home = HomeScope::enter(dir.path()); + let state = dir.path().join("bootstrap-state"); + fs::create_dir_all(&state).unwrap(); + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let url = format!("http://{address}"); + let owner_path = sidecar_owner_path(&state, CodingAgent::Codex, &url); + write_sidecar_owner( + &owner_path, + std::process::id(), + &url, + "must-not-be-sent", + Some("hmac-sha256:fake-owner-fingerprint"), + ) + .unwrap(); + let (request_sender, request_receiver) = std::sync::mpsc::channel(); + let listener_thread = thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let request = read_http_request(&mut stream); + request_sender.send(request).unwrap(); + let body = format!( + r#"{{"status":"ok","service":"nemo-relay","version":"{}","bootstrap_protocol":{}}}"#, + env!("CARGO_PKG_VERSION"), + BOOTSTRAP_PROTOCOL_VERSION + ); + stream + .write_all( + format!( + "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ) + .as_bytes(), + ) + .unwrap(); + }); + + let error = stop_owned_sidecar_record(CodingAgent::Codex, &state, &owner_path).unwrap_err(); + + assert!(error.contains("foreign listener"), "{error}"); + let request = request_receiver + .recv_timeout(Duration::from_secs(2)) + .unwrap(); + let request = String::from_utf8(request).unwrap(); + assert!(request.starts_with("GET /healthz HTTP/1.1"), "{request}"); + assert!(!request.contains("must-not-be-sent"), "{request}"); + assert!(owner_path.exists()); + listener_thread.join().unwrap(); +} + +#[cfg(unix)] +#[test] +fn failed_sidecar_startup_terminates_the_detached_process_group() { + let dir = tempdir().unwrap(); + let grandchild_pid_path = dir.path().join("grandchild.pid"); + let mut command = std::process::Command::new("sh"); + command + .args(["-c", "sleep 30 & echo $! > \"$1\"; exit 1", "sh"]) + .arg(&grandchild_pid_path); + configure_detached_sidecar(&mut command); + let mut child = command.spawn().unwrap(); + let deadline = Instant::now() + Duration::from_secs(3); + while !grandchild_pid_path.exists() && Instant::now() < deadline { + thread::sleep(Duration::from_millis(10)); + } + let grandchild_pid = fs::read_to_string(&grandchild_pid_path) + .unwrap() + .trim() + .parse::() + .unwrap(); + assert!(!child.wait().unwrap().success()); + + terminate_sidecar_process_tree(&mut child); + + let deadline = Instant::now() + Duration::from_secs(3); + loop { + // SAFETY: Signal 0 performs an existence check and does not alter the target process. + let result = unsafe { libc::kill(grandchild_pid, 0) }; + if result == -1 && std::io::Error::last_os_error().raw_os_error() == Some(libc::ESRCH) { + break; + } + if Instant::now() >= deadline { + // SAFETY: Best-effort cleanup of the test process if the process-group assertion fails. + unsafe { libc::kill(grandchild_pid, libc::SIGKILL) }; + panic!("detached sidecar grandchild {grandchild_pid} survived startup termination"); + } + thread::sleep(Duration::from_millis(20)); + } +} + +#[cfg(windows)] +#[test] +fn windows_sidecar_job_terminates_an_assigned_process() { + let job = SidecarJob::create().unwrap(); + assert!(job.name().starts_with("Local\\NeMoRelaySidecar-")); + let mut command = std::process::Command::new("cmd"); + command.args(["/C", "ping -n 30 127.0.0.1 >NUL"]); + configure_detached_sidecar(&mut command); + let mut child = command.spawn().unwrap(); + match job.assign(&child) { + Ok(()) => { + job.terminate(); + assert!(!child.wait().unwrap().success()); + } + Err(error) => { + // Restrictive host Job Objects may forbid nested assignment; production reports the + // error and refuses to serve without its process-tree cleanup guarantee. + assert!(error.contains("Job Object"), "{error}"); + let _ = child.kill(); + let _ = child.wait(); + } + } +} + +#[test] +fn sidecar_reaper_removes_only_the_exited_process_records() { + let dir = tempdir().unwrap(); + let url = "http://127.0.0.1:47632"; + let owner_path = sidecar_owner_path(dir.path(), CodingAgent::Codex, url); + let pid_path = sidecar_pid_path(dir.path(), CodingAgent::Codex, url); + let endpoint_lock = lock_sidecar_endpoint(dir.path(), url).unwrap(); + #[cfg(windows)] + let mut command = { + let mut command = std::process::Command::new("cmd"); + command.args(["/C", "ping -n 2 127.0.0.1 >NUL"]); + command + }; + #[cfg(not(windows))] + let mut command = { + let mut command = std::process::Command::new("sh"); + command.args(["-c", "sleep 0.1"]); + command + }; + let child = command.spawn().unwrap(); + let pid = child.id(); + write_sidecar_owner( + &owner_path, + pid, + url, + "test-shutdown-token", + Some("test-fingerprint"), + ) + .unwrap(); + fs::write(&pid_path, pid.to_string()).unwrap(); + + handoff_sidecar_to_reaper( + child, + owner_path.clone(), + pid_path.clone(), + sidecar_lock_path(dir.path(), url), ) .unwrap(); + drop(endpoint_lock); + + let deadline = Instant::now() + Duration::from_secs(3); + while (owner_path.exists() || pid_path.exists()) && Instant::now() < deadline { + thread::sleep(Duration::from_millis(20)); + } + assert!(!owner_path.exists()); + assert!(!pid_path.exists()); +} + +#[test] +fn sidecar_reaper_does_not_block_other_cleanup_on_a_contended_lock() { + let dir = tempdir().unwrap(); + let blocked_url = "http://127.0.0.1:47632"; + let free_url = "http://127.0.0.1:47633"; + let blocked_lock = lock_sidecar_endpoint(dir.path(), blocked_url).unwrap(); + let mut records = Vec::new(); + for url in [blocked_url, free_url] { + let child = short_lived_command().spawn().unwrap(); + let pid = child.id(); + let owner_path = sidecar_owner_path(dir.path(), CodingAgent::Codex, url); + let pid_path = sidecar_pid_path(dir.path(), CodingAgent::Codex, url); + write_sidecar_owner( + &owner_path, + pid, + url, + "test-shutdown-token", + Some("test-fingerprint"), + ) + .unwrap(); + fs::write(&pid_path, pid.to_string()).unwrap(); + handoff_sidecar_to_reaper( + child, + owner_path.clone(), + pid_path.clone(), + sidecar_lock_path(dir.path(), url), + ) + .unwrap(); + records.push((owner_path, pid_path)); + } + + let deadline = Instant::now() + Duration::from_secs(3); + while (records[1].0.exists() || records[1].1.exists()) && Instant::now() < deadline { + thread::sleep(Duration::from_millis(20)); + } + assert!(!records[1].0.exists()); + assert!(!records[1].1.exists()); + assert!(records[0].0.exists()); + + drop(blocked_lock); + let deadline = Instant::now() + Duration::from_secs(3); + while (records[0].0.exists() || records[0].1.exists()) && Instant::now() < deadline { + thread::sleep(Duration::from_millis(20)); + } + assert!(!records[0].0.exists()); + assert!(!records[0].1.exists()); +} + +#[test] +fn sidecar_state_directory_does_not_follow_runtime_environment() { + let dir = tempdir().unwrap(); + let _home = HomeScope::enter(dir.path()); + let _config_home = EnvVarGuard::remove("XDG_CONFIG_HOME"); + let first = { + let _runtime = EnvVarGuard::set_path("XDG_RUNTIME_DIR", &dir.path().join("runtime-a")); + sidecar_state_dir().unwrap() + }; + let second = { + let _runtime = EnvVarGuard::set_path("XDG_RUNTIME_DIR", &dir.path().join("runtime-b")); + sidecar_state_dir().unwrap() + }; - assert!(repair_stale_lock_after(&lock, Duration::ZERO)); - assert!(!lock.exists()); + assert_eq!(first, second); } #[test] @@ -1135,12 +2875,33 @@ fn codex_hook_command_uses_cmd_quoting_for_windows_paths() { 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!( + codex_plugin_hook_command_for_platform(&relay, true), + 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 windows_sidecar_flags_only_request_permitted_job_breakaway() { + let base = WINDOWS_CREATE_NEW_PROCESS_GROUP | WINDOWS_CREATE_NO_WINDOW; + assert_eq!(windows_sidecar_creation_flags(false, None), (base, false)); + assert_eq!( + windows_sidecar_creation_flags(true, Some(WINDOWS_JOB_OBJECT_LIMIT_BREAKAWAY_OK)), + (base | WINDOWS_CREATE_BREAKAWAY_FROM_JOB, false) + ); + assert_eq!( + windows_sidecar_creation_flags(true, Some(WINDOWS_JOB_OBJECT_LIMIT_SILENT_BREAKAWAY_OK)), + (base, false) + ); + assert_eq!(windows_sidecar_creation_flags(true, Some(0)), (base, true)); + assert_eq!(windows_sidecar_creation_flags(true, None), (base, true)); + assert_eq!(WINDOWS_JOB_OBJECT_LIMIT_KILL_ON_CLOSE, 0x0000_2000); +} + #[test] fn codex_hook_command_uses_posix_single_quote_escaping() { let relay = std::path::PathBuf::from("/tmp/NeMo $Relay`test'/bin/nemo-relay"); @@ -1150,6 +2911,10 @@ fn codex_hook_command_uses_posix_single_quote_escaping() { command, "'/tmp/NeMo $Relay`test'\\''/bin/nemo-relay' plugin-shim hook codex --gateway-url http://127.0.0.1:47632" ); + assert_eq!( + codex_plugin_hook_command_for_platform(&relay, false), + "'/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), @@ -1161,11 +2926,11 @@ fn codex_hook_command_uses_posix_single_quote_escaping() { 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")); + assert!(error.to_string().contains("hook forward failed")); } #[test] -fn hook_forward_posts_to_local_sidecar_and_healthz_accepts_200() { +fn hook_forward_posts_to_local_sidecar_and_healthz_verifies_relay_identity() { 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 || { @@ -1194,14 +2959,47 @@ fn hook_forward_posts_to_local_sidecar_and_healthz_accepts_200() { let (mut stream, _) = health_listener.accept().unwrap(); let request = read_http_request(&mut stream); assert!(String::from_utf8_lossy(&request).starts_with("GET /healthz")); + let body = format!( + r#"{{"status":"ok","service":"nemo-relay","version":"{}","bootstrap_protocol":1}}"#, + env!("CARGO_PKG_VERSION") + ); stream - .write_all(b"HTTP/1.1 200 OK\r\nConnection: close\r\n\r\n") + .write_all( + format!( + "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + body.len() + ) + .as_bytes(), + ) .unwrap(); + stream.write_all(body.as_bytes()).unwrap(); }); assert!(healthz(&format!("http://127.0.0.1:{health_port}"))); health_thread.join().unwrap(); } +#[test] +fn hook_forward_rejects_an_oversized_http_response() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + let server = thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let _ = read_http_request(&mut stream); + let _ = stream.write_all(b"HTTP/1.1 200 OK\r\nConnection: close\r\n\r\n"); + let _ = stream.write_all(&vec![b'x'; MAX_HOOK_RESPONSE_BYTES + 1]); + }); + + let error = post_hook( + CodingAgent::Codex, + &format!("http://127.0.0.1:{port}"), + b"{}", + ) + .unwrap_err(); + + assert!(error.to_string().contains("response exceeds")); + server.join().unwrap(); +} + #[test] fn hook_http_response_requires_numeric_2xx_status() { assert_eq!( @@ -1234,6 +3032,28 @@ fn unready_sidecar_child_is_terminated_and_pid_removed() { assert!(!pid_path.exists()); } +#[cfg(unix)] +#[test] +fn spawned_sidecar_uses_an_independent_process_group() { + let mut command = long_lived_command(); + configure_detached_sidecar(&mut command); + let mut child = command.spawn().unwrap(); + + let output = std::process::Command::new("ps") + .args(["-o", "pgid=", "-p", &child.id().to_string()]) + .output() + .unwrap(); + let process_group = String::from_utf8(output.stdout) + .unwrap() + .trim() + .parse::() + .unwrap(); + + assert_eq!(process_group, child.id()); + child.kill().unwrap(); + child.wait().unwrap(); +} + #[test] fn ensure_sidecar_releases_lock_when_startup_fails_fast() { let dir = tempdir().unwrap(); @@ -1241,8 +3061,11 @@ fn ensure_sidecar_releases_lock_when_startup_fails_fast() { let runtime = dir.path().join("runtime"); let _runtime = EnvVarGuard::set_path("XDG_RUNTIME_DIR", &runtime); - ensure_sidecar(CodingAgent::Codex, "not a loopback url"); + let error = loopback_bind("not a loopback url") + .and_then(|bind| GatewaySpec::new(CodingAgent::Codex, bind).ensure()) + .unwrap_err(); + assert!(error.contains("loopback URL")); assert!( !runtime .join("nemo-relay-plugin") @@ -1251,7 +3074,119 @@ fn ensure_sidecar_releases_lock_when_startup_fails_fast() { ); } -#[cfg(not(windows))] +#[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..4 { + 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 = ensure_sidecar_bind(CodingAgent::Codex, address).unwrap_err(); + assert!( + error.contains("not a compatible NeMo Relay gateway"), + "{error}" + ); + handle.join().unwrap(); +} + +#[test] +fn startup_reprobes_a_transient_foreign_health_result_after_locking() { + 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 || { + let (mut starting, _) = listener.accept().unwrap(); + let _ = read_http_request(&mut starting); + starting + .write_all(b"HTTP/1.1 503 Starting\r\nContent-Length: 0\r\nConnection: close\r\n\r\n") + .unwrap(); + drop(starting); + + let (mut ready, _) = listener.accept().unwrap(); + let _ = read_http_request(&mut ready); + let body = format!( + r#"{{"status":"ok","service":"nemo-relay","version":"{}","bootstrap_protocol":{}}}"#, + env!("CARGO_PKG_VERSION"), + BOOTSTRAP_PROTOCOL_VERSION + ); + ready + .write_all( + format!( + "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ) + .as_bytes(), + ) + .unwrap(); + }); + + let endpoint = ensure_sidecar_bind(CodingAgent::Codex, address).unwrap(); + + assert_eq!(endpoint.address, address); + handle.join().unwrap(); +} + +#[test] +fn active_startup_lock_waits_for_relay_identity_instead_of_rejecting_listener() { + let dir = tempdir().unwrap(); + let _home = HomeScope::enter(dir.path()); + let runtime_base = dir.path().join("runtime"); + let _runtime = EnvVarGuard::set_path("XDG_RUNTIME_DIR", &runtime_base); + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let state = sidecar_state_dir().unwrap(); + fs::create_dir_all(&state).unwrap(); + let url = format!("http://{address}"); + let lock_path = sidecar_lock_path(&state, &url); + let owner_lock = lock_sidecar_endpoint(&state, &url).unwrap(); + let handle = thread::spawn(move || { + let (mut starting, _) = listener.accept().unwrap(); + let _ = read_http_request(&mut starting); + starting + .write_all(b"HTTP/1.1 503 Starting\r\nContent-Length: 0\r\nConnection: close\r\n\r\n") + .unwrap(); + drop(starting); + + let (mut ready, _) = listener.accept().unwrap(); + let _ = read_http_request(&mut ready); + let body = format!( + r#"{{"status":"ok","service":"nemo-relay","version":"{}","bootstrap_protocol":{}}}"#, + env!("CARGO_PKG_VERSION"), + BOOTSTRAP_PROTOCOL_VERSION + ); + ready + .write_all( + format!( + "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ) + .as_bytes(), + ) + .unwrap(); + drop(owner_lock); + }); + + let endpoint = ensure_sidecar_bind(CodingAgent::Codex, address).unwrap(); + + assert_eq!(endpoint.address, address); + assert_eq!(endpoint.url, format!("http://{address}")); + handle.join().unwrap(); + assert!(lock_path.exists()); +} + +#[cfg(unix)] #[test] fn start_sidecar_reports_child_exit_before_healthz_ready() { use std::os::unix::fs::PermissionsExt; @@ -1265,18 +3200,9 @@ fn start_sidecar_reports_child_exit_before_healthz_ready() { 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(); + let error = start_sidecar(CodingAgent::Codex, "http://127.0.0.1:0", dir.path()).unwrap_err(); - assert!(error.contains("exited before becoming ready")); + assert!(error.contains("exited before becoming ready"), "{error}"); assert!(!dir.path().join("codex-sidecar.pid").exists()); } @@ -1379,6 +3305,13 @@ fn long_lived_command() -> std::process::Command { command } +#[cfg(windows)] +fn short_lived_command() -> std::process::Command { + let mut command = std::process::Command::new("cmd"); + command.args(["/C", "ping -n 2 127.0.0.1 >NUL"]); + command +} + #[cfg(not(windows))] fn long_lived_command() -> std::process::Command { let mut command = std::process::Command::new("sh"); @@ -1386,6 +3319,13 @@ fn long_lived_command() -> std::process::Command { command } +#[cfg(not(windows))] +fn short_lived_command() -> std::process::Command { + let mut command = std::process::Command::new("sh"); + command.args(["-c", "sleep 0.1"]); + command +} + #[test] fn codex_install_hooks_persist_custom_gateway_url() { let dir = tempdir().unwrap(); @@ -1402,15 +3342,26 @@ fn codex_install_hooks_persist_custom_gateway_url() { } #[test] -fn codex_install_hooks_replaces_legacy_generated_command() { +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 legacy = generated_hooks(CodingAgent::Codex, &legacy_command); - fs::write(&path, serde_json::to_vec_pretty(&legacy).unwrap()).unwrap(); + 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(); - install_codex_hooks(&path, DEFAULT_URL).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( @@ -1421,7 +3372,44 @@ fn codex_install_hooks_replaces_legacy_generated_command() { assert!(event_contains_command( &updated, "SessionStart", - &codex_hook_command(DEFAULT_URL) + "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" )); } @@ -1436,9 +3424,11 @@ fn codex_install_does_not_write_provider_config_when_hooks_are_invalid() { "model_provider = \"openai\"\n", ) .unwrap(); - fs::write(codex_dir.join("hooks.json"), "{ invalid json").unwrap(); + let plugin_hooks = dir.path().join("plugin").join("hooks").join("hooks.json"); + fs::create_dir_all(plugin_hooks.parent().unwrap()).unwrap(); + fs::write(&plugin_hooks, "{ invalid json").unwrap(); - let error = install_codex(DEFAULT_URL).unwrap_err(); + let error = install_codex(DEFAULT_URL, &plugin_hooks).unwrap_err(); assert!(error.contains("invalid JSON")); assert_eq!( @@ -1474,7 +3464,12 @@ fn codex_install_does_not_write_hooks_when_config_is_invalid() { .unwrap(); fs::write(&hooks_path, &original_hooks).unwrap(); - let error = install_codex(DEFAULT_URL).unwrap_err(); + 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); @@ -1507,7 +3502,12 @@ fn codex_install_does_not_write_hooks_when_config_is_not_readable() { .unwrap(); fs::write(&hooks_path, &original_hooks).unwrap(); - let error = install_codex(DEFAULT_URL).unwrap_err(); + 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); @@ -1528,7 +3528,7 @@ fn codex_install_config_rolls_back_backup_when_write_fails() { } #[test] -fn codex_install_rolls_back_hooks_backup_when_hook_merge_fails() { +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"); @@ -1547,9 +3547,14 @@ fn codex_install_rolls_back_hooks_backup_when_hook_merge_fails() { .unwrap(); fs::write(&hooks_path, &original_hooks).unwrap(); - let error = install_codex(DEFAULT_URL).unwrap_err(); + 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("SessionStart hooks must be an array")); + 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!( @@ -1569,7 +3574,8 @@ fn codex_uninstall_rolls_back_hooks_when_provider_config_is_invalid() { install_codex_hooks(&hooks_path, DEFAULT_URL).unwrap(); let original_hooks = fs::read(&hooks_path).unwrap(); - let error = uninstall_codex(DEFAULT_URL).unwrap_err(); + 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); @@ -1606,7 +3612,8 @@ fn codex_install_rolls_back_hooks_when_provider_config_write_fails() { .unwrap(); fs::write(&hooks_path, &original_hooks).unwrap(); - let error = install_codex(DEFAULT_URL).unwrap_err(); + 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); @@ -1659,18 +3666,32 @@ base_url = "http://127.0.0.1:47633" 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 (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; }; - thread::sleep(Duration::from_secs(2)); + 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(); + }); - let started = Instant::now(); - assert!(!healthz(&format!("http://127.0.0.1:{port}"))); - assert!(started.elapsed() < Duration::from_secs(2)); - handle.join().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] @@ -1725,16 +3746,6 @@ fn shared_filesystem_helpers_cover_tables_snapshots_and_lock_branches() { 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] @@ -1759,7 +3770,11 @@ fn shared_url_env_and_response_helpers_cover_error_branches() { gateway_url(CodingAgent::Codex, Some("http://127.0.0.1:9")), "http://127.0.0.1:9" ); - assert_eq!(plugin_idle_timeout(), "7"); + assert_eq!(plugin_idle_timeout().unwrap(), Duration::from_secs(7)); + assert_eq!( + plugin_heartbeat_interval().unwrap(), + Duration::from_secs(7) / 3 + ); assert!(fail_closed()); assert_eq!( @@ -1797,6 +3812,10 @@ fn shared_url_env_and_response_helpers_cover_error_branches() { parse_loopback_url("http://localhost:47632/path").unwrap(), ("localhost".to_string(), 47632) ); + assert_eq!( + parse_loopback_url("http://[::1]:47632/path").unwrap(), + ("::1".to_string(), 47632) + ); assert!( parse_loopback_url("https://127.0.0.1:47632") .unwrap_err() @@ -1815,7 +3834,7 @@ fn shared_url_env_and_response_helpers_cover_error_branches() { assert!( parse_loopback_url("http://127.0.0.1:nope") .unwrap_err() - .contains("invalid gateway port") + .contains("invalid plugin shim loopback URL") ); assert_eq!( @@ -1844,7 +3863,11 @@ fn shared_defaults_cover_runtime_username_and_empty_segments() { 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_eq!(plugin_idle_timeout().unwrap(), Duration::from_secs(300)); + assert_eq!( + plugin_heartbeat_interval().unwrap(), + Duration::from_secs(30) + ); assert!(!fail_closed()); assert_eq!( runtime_dir_for( @@ -1855,7 +3878,9 @@ fn shared_defaults_cover_runtime_username_and_empty_segments() { None, Some("bob/name".into()), ), - std::path::PathBuf::from("/tmp/temp-base").join("nemo-relay-plugin") + std::path::PathBuf::from("/tmp/temp-base") + .join("bob_name") + .join("nemo-relay-plugin") ); assert_eq!(sidecar_lock_name(""), "unknown"); assert_eq!( @@ -2018,29 +4043,48 @@ fn plugin_shim_entrypoints_reject_unsupported_agents_and_report_json() { .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(); + 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).unwrap(); + let report = doctor_plugin_json(CodingAgent::ClaudeCode, DEFAULT_URL, &plugin_root).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(); + let codex_report = doctor_plugin_json(CodingAgent::Codex, DEFAULT_URL, &plugin_root).unwrap(); assert_eq!( codex_report["sidecar_health"], - json!("not_running_lazy_start") + 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) + doctor_plugin_json(CodingAgent::Hermes, DEFAULT_URL, &plugin_root) .unwrap_err() .contains("supports claude and codex") ); assert!( - doctor_plugin(CodingAgent::Hermes, DEFAULT_URL) + doctor_plugin(CodingAgent::Hermes, DEFAULT_URL, &plugin_root) .unwrap_err() .contains("supports claude and codex") ); assert!( - doctor_plugin(CodingAgent::Codex, DEFAULT_URL) + doctor_plugin(CodingAgent::Codex, DEFAULT_URL, &plugin_root) .unwrap_err() .contains("codex plugin doctor checks failed") ); @@ -2072,6 +4116,7 @@ fn plugin_shim_entrypoints_reject_unsupported_agents_and_report_json() { assert!( post_hook(CodingAgent::Hermes, DEFAULT_URL, b"{}") .unwrap_err() + .to_string() .contains("supports claude and codex") ); } diff --git a/crates/cli/tests/coverage/plugins_lifecycle_tests.rs b/crates/cli/tests/coverage/plugins_lifecycle_tests.rs index 2781cbc00..eac8e24ed 100644 --- a/crates/cli/tests/coverage/plugins_lifecycle_tests.rs +++ b/crates/cli/tests/coverage/plugins_lifecycle_tests.rs @@ -14,11 +14,92 @@ use crate::config::{ }; use crate::error::PluginLifecycleFailureKind; 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, } @@ -592,6 +673,450 @@ fn tracked_native_plugin_example_rejects_tampered_artifact() { ); } +#[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::config::MAX_BOOTSTRAP_IDENTITY_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::config::MAX_BOOTSTRAP_IDENTITY_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 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(); + + let error = + environment::test_environment_tree_digest_with_entry_limit(temp.path(), 2).unwrap_err(); + + assert!(error.contains("2-entry attestation budget"), "{error}"); +} + +#[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}" + ); + + 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 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 add_registers_dynamic_plugin_in_project_plugins_toml() { let temp = tempfile::tempdir().unwrap(); @@ -1099,6 +1624,72 @@ 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( + PluginsAddCommand { + scope: PluginsScopeArgs { + project: true, + ..PluginsScopeArgs::default() + }, + path: plugin_dir, + }, + &ServerArgs::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(); diff --git a/crates/cli/tests/coverage/server_tests.rs b/crates/cli/tests/coverage/server_tests.rs index 25d7ac598..00b8bfeb7 100644 --- a/crates/cli/tests/coverage/server_tests.rs +++ b/crates/cli/tests/coverage/server_tests.rs @@ -30,6 +30,7 @@ use tokio::task::JoinHandle; use tower::ServiceExt; use super::*; +use crate::config::BootstrapChallengeKey; use crate::error::CliError; use crate::plugins::lifecycle::ActiveDynamicPluginComponent; use crate::test_support::PLUGIN_CONFIG_TEST_LOCK; @@ -371,7 +372,163 @@ 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!(1)); +} + +#[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")), + 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 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()), + 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, + 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).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!(1)); + assert!(!path.with_extension("json.tmp").exists()); } #[tokio::test] @@ -462,20 +619,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 +694,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; @@ -1632,6 +1819,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"); @@ -1831,9 +2020,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/session_tests.rs index aa92a95e2..17e15cd60 100644 --- a/crates/cli/tests/coverage/session_tests.rs +++ b/crates/cli/tests/coverage/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; @@ -42,7 +42,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 { @@ -888,6 +944,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()); diff --git a/crates/cli/tests/coverage/sidecar_health_tests.rs b/crates/cli/tests/coverage/sidecar_health_tests.rs new file mode 100644 index 000000000..97583fec1 --- /dev/null +++ b/crates/cli/tests/coverage/sidecar_health_tests.rs @@ -0,0 +1,126 @@ +// 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::*; + +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) +} + +#[test] +fn shutdown_request_sends_the_private_token_and_accepts_no_content() { + let (url, request, server) = + serve_once(b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"); + + request_shutdown(&url, "private-token").unwrap(); + + let request = String::from_utf8(request.recv_timeout(Duration::from_secs(2)).unwrap()).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 (url, _, server) = + serve_once(b"HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"); + + let error = request_shutdown(&url, "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 (url, _, server) = serve_once(b"not-http"); + + let error = request_shutdown(&url, "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, "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"); +} diff --git a/crates/cli/tests/coverage/sidecar_state_tests.rs b/crates/cli/tests/coverage/sidecar_state_tests.rs new file mode 100644 index 000000000..e6cbb48c1 --- /dev/null +++ b/crates/cli/tests/coverage/sidecar_state_tests.rs @@ -0,0 +1,403 @@ +// 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, Write}; +use std::net::{TcpListener, TcpStream}; +use std::path::Path; +use std::thread; +use std::time::Duration; + +use tempfile::tempdir; + +use super::*; + +const SHUTDOWN_TOKEN_ENV: &str = "NEMO_RELAY_BOOTSTRAP_SHUTDOWN_TOKEN"; + +struct Environment { + _lock: std::sync::MutexGuard<'static, ()>, + saved: Vec<(&'static str, Option)>, +} + +impl Environment { + fn isolated() -> Self { + let lock = crate::test_support::ENV_TEST_LOCK + .lock() + .unwrap_or_else(|error| error.into_inner()); + let keys = [ + "HOME", + "USERPROFILE", + "XDG_CONFIG_HOME", + BOOTSTRAP_STATE_DIR_ENV, + BOOTSTRAP_AGENT_ENV, + SHUTDOWN_TOKEN_ENV, + crate::config::BOOTSTRAP_FINGERPRINT_ENV, + ]; + let saved = keys + .into_iter() + .map(|key| (key, std::env::var_os(key))) + .collect(); + Self { _lock: lock, saved } + } + + fn set(&self, key: &'static str, value: impl AsRef) { + // SAFETY: This scope holds the process-wide environment test lock. + unsafe { std::env::set_var(key, value) }; + } + + fn remove(&self, key: &'static str) { + // SAFETY: This scope holds the process-wide environment test lock. + unsafe { std::env::remove_var(key) }; + } + + fn clear_managed_bootstrap(&self) { + self.remove(BOOTSTRAP_STATE_DIR_ENV); + self.remove(BOOTSTRAP_AGENT_ENV); + self.remove(SHUTDOWN_TOKEN_ENV); + self.remove(crate::config::BOOTSTRAP_FINGERPRINT_ENV); + } +} + +impl Drop for Environment { + fn drop(&mut self) { + // SAFETY: Restoration happens while the process-wide environment test lock is held. + unsafe { + for (key, value) in self.saved.drain(..).rev() { + match value { + Some(value) => std::env::set_var(key, value), + None => std::env::remove_var(key), + } + } + } + } +} + +fn configure_managed_bootstrap(environment: &Environment, state: &Path) { + environment.set(BOOTSTRAP_STATE_DIR_ENV, state); + environment.set(BOOTSTRAP_AGENT_ENV, "codex"); + environment.set(SHUTDOWN_TOKEN_ENV, "shutdown-token"); + environment.set(crate::config::BOOTSTRAP_FINGERPRINT_ENV, "fingerprint"); +} + +fn read_http_headers(stream: &mut TcpStream) -> String { + 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") { + let read = stream.read(&mut buffer).unwrap(); + if read == 0 { + break; + } + request.extend_from_slice(&buffer[..read]); + } + String::from_utf8(request).unwrap() +} + +fn request_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} in request: {request}")) +} + +#[test] +fn state_files_distinguish_absence_from_read_failure() { + let dir = tempdir().unwrap(); + let missing = dir.path().join("missing.json"); + + assert!(read_owner_record(&missing).unwrap().is_none()); + assert!(read_ready_file(&missing).unwrap().is_none()); + + let owner_error = read_owner_record(dir.path()).unwrap_err(); + assert!(owner_error.contains("failed to read sidecar ownership")); + let ready_error = read_ready_file(dir.path()).unwrap_err(); + assert!(ready_error.contains("failed to read sidecar readiness file")); +} + +#[test] +fn state_directory_requires_a_user_configuration_home() { + let environment = Environment::isolated(); + environment.remove("HOME"); + environment.remove("USERPROFILE"); + environment.remove("XDG_CONFIG_HOME"); + + let error = state_dir().unwrap_err(); + + assert!(error.contains("cannot determine"), "{error}"); +} + +#[test] +fn managed_owner_environment_is_validated_before_writing_state() { + let dir = tempdir().unwrap(); + let environment = Environment::isolated(); + environment.clear_managed_bootstrap(); + + environment.set(BOOTSTRAP_STATE_DIR_ENV, "relative-state"); + environment.set(BOOTSTRAP_AGENT_ENV, "codex"); + environment.set(SHUTDOWN_TOKEN_ENV, "shutdown-token"); + let error = publish_owner_from_env("127.0.0.1:47632".parse().unwrap()).unwrap_err(); + assert!(error.contains("must be an absolute path"), "{error}"); + + environment.set(BOOTSTRAP_STATE_DIR_ENV, dir.path()); + environment.set(BOOTSTRAP_AGENT_ENV, "unsupported"); + let error = publish_owner_from_env("127.0.0.1:47632".parse().unwrap()).unwrap_err(); + assert!(error.contains("unsupported bootstrap agent"), "{error}"); + + environment.remove(BOOTSTRAP_AGENT_ENV); + let error = publish_owner_from_env("127.0.0.1:47632".parse().unwrap()).unwrap_err(); + assert!(error.contains(BOOTSTRAP_AGENT_ENV), "{error}"); + + environment.set(BOOTSTRAP_AGENT_ENV, "codex"); + environment.set(SHUTDOWN_TOKEN_ENV, ""); + let error = publish_owner_from_env("127.0.0.1:47632".parse().unwrap()).unwrap_err(); + assert!(error.contains(SHUTDOWN_TOKEN_ENV), "{error}"); + + environment.set(SHUTDOWN_TOKEN_ENV, "shutdown-token"); + let error = publish_owner_from_env("0.0.0.0:47632".parse().unwrap()).unwrap_err(); + assert!(error.contains("requires a loopback address"), "{error}"); + + let state_file = dir.path().join("state-file"); + std::fs::write(&state_file, "not a directory").unwrap(); + environment.set(BOOTSTRAP_STATE_DIR_ENV, &state_file); + let error = publish_owner_from_env("127.0.0.1:47632".parse().unwrap()).unwrap_err(); + assert!( + error.contains("failed to create bootstrap state directory"), + "{error}" + ); +} + +#[test] +fn managed_owner_supports_claude_code_identity() { + let dir = tempdir().unwrap(); + let state = dir.path().join("state"); + let environment = Environment::isolated(); + configure_managed_bootstrap(&environment, &state); + environment.set(BOOTSTRAP_AGENT_ENV, "claude-code"); + let address = "127.0.0.1:47633".parse().unwrap(); + + publish_owner_from_env(address).unwrap(); + + let url = format!("http://{address}"); + let owner = owner_path(&state, CodingAgent::ClaudeCode, &url); + let pid = pid_path(&state, CodingAgent::ClaudeCode, &url); + validate_owner( + &owner, + &pid, + std::process::id(), + &url, + "shutdown-token", + Some("fingerprint"), + ) + .unwrap(); +} + +#[test] +fn failed_owner_publish_removes_the_partial_pid_record() { + let dir = tempdir().unwrap(); + let state = dir.path().join("state"); + std::fs::create_dir_all(&state).unwrap(); + let environment = Environment::isolated(); + configure_managed_bootstrap(&environment, &state); + let address = "127.0.0.1:47632".parse().unwrap(); + let url = format!("http://{address}"); + let owner = owner_path(&state, CodingAgent::Codex, &url); + let pid = pid_path(&state, CodingAgent::Codex, &url); + std::fs::create_dir_all(&owner).unwrap(); + #[cfg(windows)] + { + let file_name = owner.file_name().unwrap().to_string_lossy(); + let stale_backup = owner.with_file_name(format!(".{file_name}.nemo-relay-replace.tmp")); + std::fs::create_dir_all(stale_backup).unwrap(); + } + + let error = publish_owner_from_env(address).unwrap_err(); + + assert!(error.contains("failed to"), "{error}"); + assert!(!pid.exists()); +} + +#[test] +fn owner_validation_reports_a_missing_record() { + let dir = tempdir().unwrap(); + let owner = dir.path().join("missing-owner.json"); + let pid = dir.path().join("missing-owner.pid"); + + let error = validate_owner( + &owner, + &pid, + 42, + "http://127.0.0.1:47632", + "shutdown-token", + Some("fingerprint"), + ) + .unwrap_err(); + + assert!(error.contains("file does not exist"), "{error}"); +} + +#[test] +fn owner_enumeration_distinguishes_missing_and_unreadable_directories() { + let dir = tempdir().unwrap(); + assert!( + owner_paths(&dir.path().join("missing"), CodingAgent::Codex) + .unwrap() + .is_empty() + ); + + let file = dir.path().join("not-a-directory"); + std::fs::write(&file, "file").unwrap(); + let error = owner_paths(&file, CodingAgent::Codex).unwrap_err(); + assert!(error.contains("failed to enumerate"), "{error}"); +} + +#[test] +fn stale_owner_cleanup_requires_shutdown_credentials() { + let dir = tempdir().unwrap(); + let url = "http://127.0.0.1:9"; + let owner = owner_path(dir.path(), CodingAgent::Codex, url); + + assert!( + stop_owned_record(CodingAgent::Codex, dir.path(), &owner).is_ok(), + "an already absent owner is clean" + ); + + write_owner(&owner, 42, url, "", Some("fingerprint")).unwrap(); + let error = stop_owned_record(CodingAgent::Codex, dir.path(), &owner).unwrap_err(); + assert!(error.contains("has no shutdown token"), "{error}"); + + write_owner(&owner, 42, url, "shutdown-token", None).unwrap(); + let error = stop_owned_record(CodingAgent::Codex, dir.path(), &owner).unwrap_err(); + assert!( + error.contains("no authenticated bootstrap fingerprint"), + "{error}" + ); +} + +#[test] +fn unavailable_owned_sidecar_is_removed_without_sending_shutdown() { + let dir = tempdir().unwrap(); + let url = "http://127.0.0.1:9"; + let owner = owner_path(dir.path(), CodingAgent::Codex, url); + let pid = pid_path(dir.path(), CodingAgent::Codex, url); + write_owner(&owner, 42, url, "shutdown-token", Some("fingerprint")).unwrap(); + std::fs::write(&pid, "42").unwrap(); + + stop_owned_record(CodingAgent::Codex, dir.path(), &owner).unwrap(); + + assert!(!owner.exists()); + assert!(!pid.exists()); +} + +#[test] +fn unavailable_legacy_owner_removes_the_legacy_pid_record() { + let dir = tempdir().unwrap(); + let url = "http://127.0.0.1:9"; + let owner = dir.path().join("codex-sidecar.owner.json"); + let pid = dir.path().join("codex-sidecar.pid"); + write_owner(&owner, 42, url, "shutdown-token", Some("fingerprint")).unwrap(); + std::fs::write(&pid, "42").unwrap(); + + stop_owned_record(CodingAgent::Codex, dir.path(), &owner).unwrap(); + + assert!(!owner.exists()); + assert!(!pid.exists()); +} + +#[test] +fn authenticated_owned_sidecar_is_shut_down_and_cleaned_up() { + let dir = tempdir().unwrap(); + let environment = Environment::isolated(); + environment.set("XDG_CONFIG_HOME", dir.path().join("config")); + environment.set("HOME", dir.path()); + environment.remove("USERPROFILE"); + let key = crate::config::BootstrapChallengeKey::load().unwrap(); + let reloaded_key = crate::config::BootstrapChallengeKey::load().unwrap(); + assert!(reloaded_key.verify( + "fingerprint", + "test-nonce", + &key.proof("fingerprint", "test-nonce") + )); + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let url = format!("http://{}", listener.local_addr().unwrap()); + let owner = owner_path(dir.path(), CodingAgent::Codex, &url); + let pid = pid_path(dir.path(), CodingAgent::Codex, &url); + write_owner(&owner, 42, &url, "shutdown-token", Some("fingerprint")).unwrap(); + std::fs::write(&pid, "42").unwrap(); + let server = thread::spawn(move || { + for _ in 0..2 { + let (mut health, _) = listener.accept().unwrap(); + let request = read_http_headers(&mut health); + let fingerprint = request_header(&request, "x-nemo-relay-bootstrap-fingerprint"); + let nonce = request_header(&request, "x-nemo-relay-bootstrap-nonce"); + let proof = key.proof(&fingerprint, &nonce); + let body = format!( + r#"{{"status":"ok","service":"nemo-relay","version":"{}","bootstrap_protocol":{}}}"#, + 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, _) = listener.accept().unwrap(); + let request = read_http_headers(&mut shutdown); + assert!(request.starts_with("POST /bootstrap/shutdown HTTP/1.1")); + assert_eq!( + request_header(&request, "x-nemo-relay-bootstrap-token"), + "shutdown-token" + ); + shutdown + .write_all(b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\nConnection: close\r\n\r\n") + .unwrap(); + }); + + assert_eq!(probe(&url, Some("fingerprint")), RelayHealth::Compatible); + stop_owned_record(CodingAgent::Codex, dir.path(), &owner).unwrap(); + + server.join().unwrap(); + assert!(!owner.exists()); + assert!(!pid.exists()); +} + +#[test] +fn stop_owned_aggregates_record_errors() { + let dir = tempdir().unwrap(); + let environment = Environment::isolated(); + environment.set("XDG_CONFIG_HOME", dir.path()); + environment.set("HOME", dir.path()); + environment.remove("USERPROFILE"); + let runtime = state_dir().unwrap(); + std::fs::create_dir_all(&runtime).unwrap(); + let url = "http://127.0.0.1:9"; + let owner = owner_path(&runtime, CodingAgent::Codex, url); + write_owner(&owner, 42, url, "", Some("fingerprint")).unwrap(); + + let error = stop_owned(CodingAgent::Codex).unwrap_err(); + + assert!(error.contains("has no shutdown token"), "{error}"); +} + +#[test] +fn stop_owned_succeeds_when_no_records_exist() { + let dir = tempdir().unwrap(); + let environment = Environment::isolated(); + environment.set("XDG_CONFIG_HOME", dir.path()); + environment.set("HOME", dir.path()); + environment.remove("USERPROFILE"); + + stop_owned(CodingAgent::Codex).unwrap(); +} diff --git a/crates/cli/tests/coverage/sidecar_tests.rs b/crates/cli/tests/coverage/sidecar_tests.rs new file mode 100644 index 000000000..8bf385ffc --- /dev/null +++ b/crates/cli/tests/coverage/sidecar_tests.rs @@ -0,0 +1,423 @@ +// 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, Write}; +use std::net::TcpListener; +use std::process::Command; +use std::thread; + +use super::*; +use serde_json::json; + +struct EnvVarRestore { + key: &'static str, + previous: Option, +} + +impl EnvVarRestore { + fn set(key: &'static str, value: &str) -> Self { + let previous = std::env::var_os(key); + // SAFETY: Callers hold the process-wide environment test lock. + unsafe { std::env::set_var(key, value) }; + Self { key, previous } + } +} + +impl Drop for EnvVarRestore { + fn drop(&mut self) { + // SAFETY: Callers retain the process-wide environment test lock until after this guard. + unsafe { + match self.previous.take() { + Some(value) => std::env::set_var(self.key, value), + None => std::env::remove_var(self.key), + } + } + } +} + +#[test] +fn gateway_spec_is_the_complete_compatibility_contract() { + let bind = "127.0.0.1:47632".parse().unwrap(); + let first = GatewaySpec::new(CodingAgent::Codex, bind) + .with_launch_args(vec![ + OsString::from("--openai-base-url"), + OsString::from("mock"), + ]) + .with_fingerprint("fingerprint-a"); + let same = GatewaySpec::new(CodingAgent::Codex, bind) + .with_launch_args(vec![ + OsString::from("--openai-base-url"), + OsString::from("mock"), + ]) + .with_fingerprint("fingerprint-a"); + let different = GatewaySpec::new(CodingAgent::Codex, bind) + .with_launch_args(vec![ + OsString::from("--openai-base-url"), + OsString::from("other"), + ]) + .with_fingerprint("fingerprint-a"); + + assert_eq!(first, same); + assert_ne!(first, different); + assert_eq!(first.bind(), bind); +} + +#[test] +fn gateway_spec_rejects_non_loopback_bind_before_launch() { + let error = GatewaySpec::new(CodingAgent::Codex, "0.0.0.0:47632".parse().unwrap()) + .ensure() + .unwrap_err(); + + assert!(error.contains("require a loopback bind address"), "{error}"); +} + +#[test] +fn typed_owner_record_round_trips_and_rejects_identity_drift() { + let dir = tempfile::tempdir().unwrap(); + let owner = dir.path().join("owner.json"); + let pid = dir.path().join("owner.pid"); + std::fs::write(&pid, "42").unwrap(); + write_sidecar_owner( + &owner, + 42, + "http://127.0.0.1:47632", + "shutdown-token", + Some("fingerprint"), + ) + .unwrap(); + + validate_sidecar_owner( + &owner, + &pid, + 42, + "http://127.0.0.1:47632", + "shutdown-token", + Some("fingerprint"), + ) + .unwrap(); + let error = validate_sidecar_owner( + &owner, + &pid, + 42, + "http://127.0.0.1:47632", + "different-token", + Some("fingerprint"), + ) + .unwrap_err(); + assert!(error.contains("does not match the ready process")); +} + +#[test] +fn typed_owner_record_rejects_missing_required_fields() { + let dir = tempfile::tempdir().unwrap(); + let owner = dir.path().join("owner.json"); + let pid = dir.path().join("owner.pid"); + std::fs::write(&owner, serde_json::to_vec(&json!({"pid": 42})).unwrap()).unwrap(); + std::fs::write(&pid, "42").unwrap(); + + let error = validate_sidecar_owner( + &owner, + &pid, + 42, + "http://127.0.0.1:47632", + "shutdown-token", + Some("fingerprint"), + ) + .unwrap_err(); + + assert!(error.contains("invalid sidecar ownership file"), "{error}"); +} + +#[test] +fn readiness_file_requires_exact_protocol_identity() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("ready.json"); + std::fs::write( + &path, + serde_json::to_vec(&json!({ + "service": "nemo-relay", + "version": env!("CARGO_PKG_VERSION"), + "bootstrap_protocol": BOOTSTRAP_PROTOCOL_VERSION, + "address": "127.0.0.1:47777" + })) + .unwrap(), + ) + .unwrap(); + + let endpoint = read_sidecar_ready_file(&path).unwrap().unwrap(); + assert_eq!(endpoint.address, "127.0.0.1:47777".parse().unwrap()); + + std::fs::write( + &path, + serde_json::to_vec(&json!({ + "service": "nemo-relay", + "version": env!("CARGO_PKG_VERSION"), + "bootstrap_protocol": BOOTSTRAP_PROTOCOL_VERSION + 1, + "address": "127.0.0.1:47777" + })) + .unwrap(), + ) + .unwrap(); + let error = read_sidecar_ready_file(&path).unwrap_err(); + assert!(error.contains("incompatible sidecar readiness file")); +} + +fn one_health_response(body: String) -> (SocketAddr, thread::JoinHandle<()>) { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let server = thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut request = [0_u8; 2048]; + let _ = stream.read(&mut request).unwrap(); + stream + .write_all( + format!( + "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ) + .as_bytes(), + ) + .unwrap(); + }); + (address, server) +} + +fn unused_loopback_address() -> SocketAddr { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + drop(listener); + address +} + +#[test] +fn direct_sidecar_start_classifies_existing_listeners_before_spawning() { + let dir = tempfile::tempdir().unwrap(); + let compatible_body = format!( + r#"{{"status":"ok","service":"nemo-relay","version":"{}","bootstrap_protocol":{}}}"#, + env!("CARGO_PKG_VERSION"), + BOOTSTRAP_PROTOCOL_VERSION + ); + let (address, server) = one_health_response(compatible_body); + let bootstrap = start_sidecar_bind( + CodingAgent::Codex, + address, + dir.path(), + dir.path(), + &[], + None, + None, + ) + .unwrap(); + assert!(!bootstrap.started); + assert_eq!(bootstrap.endpoint.address, address); + server.join().unwrap(); + + let incompatible_body = format!( + r#"{{"status":"ok","service":"nemo-relay","version":"other","bootstrap_protocol":{}}}"#, + BOOTSTRAP_PROTOCOL_VERSION + ); + let (address, server) = one_health_response(incompatible_body); + let error = start_sidecar_bind( + CodingAgent::Codex, + address, + dir.path(), + dir.path(), + &[], + None, + None, + ) + .unwrap_err(); + assert!(error.contains("different version"), "{error}"); + server.join().unwrap(); + + let (address, server) = one_health_response("{}".into()); + let error = start_sidecar_bind( + CodingAgent::Codex, + address, + dir.path(), + dir.path(), + &[], + None, + None, + ) + .unwrap_err(); + assert!(error.contains("not a compatible NeMo Relay"), "{error}"); + server.join().unwrap(); +} + +#[test] +fn sidecar_record_cleanup_is_scoped_to_the_exited_pid() { + let dir = tempfile::tempdir().unwrap(); + let invalid = sidecar_owner_path(dir.path(), CodingAgent::Codex, "http://127.0.0.1:47630"); + std::fs::write(&invalid, "not-json").unwrap(); + let matching = sidecar_owner_path(dir.path(), CodingAgent::Codex, "http://127.0.0.1:47631"); + write_sidecar_owner( + &matching, + 42, + "http://127.0.0.1:47631", + "token", + Some("fingerprint"), + ) + .unwrap(); + let matching_pid = sidecar_pid_path(dir.path(), CodingAgent::Codex, "http://127.0.0.1:47631"); + std::fs::write(&matching_pid, "42").unwrap(); + let other = sidecar_owner_path(dir.path(), CodingAgent::Codex, "http://127.0.0.1:47632"); + write_sidecar_owner( + &other, + 43, + "http://127.0.0.1:47632", + "token", + Some("fingerprint"), + ) + .unwrap(); + + cleanup_sidecar_records_for_pid(dir.path(), CodingAgent::Codex, 42); + + assert!(!matching.exists()); + assert!(!matching_pid.exists()); + assert!(invalid.exists()); + assert!(other.exists()); + + let not_a_directory = dir.path().join("runtime-file"); + std::fs::write(¬_a_directory, "file").unwrap(); + cleanup_sidecar_records_for_pid(¬_a_directory, CodingAgent::Codex, 42); +} + +fn exited_command() -> Command { + #[cfg(windows)] + { + let mut command = Command::new("cmd"); + command.args(["/C", "exit 7"]); + command + } + #[cfg(not(windows))] + { + let mut command = Command::new("sh"); + command.args(["-c", "exit 7"]); + command + } +} + +#[test] +fn an_already_exited_unready_sidecar_reports_its_status() { + let dir = tempfile::tempdir().unwrap(); + let pid_path = dir.path().join("startup.pid"); + let mut child = exited_command().spawn().unwrap(); + let pid = child.id(); + assert!(!child.wait().unwrap().success()); + std::fs::write(&pid_path, pid.to_string()).unwrap(); + + let error = terminate_unready_sidecar(child, &pid_path, DEFAULT_URL).unwrap_err(); + + assert!(error.contains("exited before becoming ready"), "{error}"); + assert!(!pid_path.exists()); +} + +#[test] +fn reaper_cleanup_treats_an_unopenable_lock_as_terminal() { + let dir = tempfile::tempdir().unwrap(); + let mut child = exited_command().spawn().unwrap(); + assert!(!child.wait().unwrap().success()); + let request = SidecarReapRequest { + process: DetachedSidecarProcess::new( + child, + #[cfg(windows)] + None, + ), + exited: true, + owner_path: dir.path().join("owner.json"), + pid_path: dir.path().join("owner.pid"), + lock_path: dir.path().join("missing-parent").join("owner.lock"), + }; + + assert!(cleanup_reaped_sidecar(&request)); +} + +#[test] +fn zero_idle_timeout_is_rejected_by_timeout_and_heartbeat_resolution() { + let _lock = crate::test_support::ENV_TEST_LOCK + .lock() + .unwrap_or_else(|error| error.into_inner()); + let key = crate::config::PLUGIN_IDLE_TIMEOUT_ENV; + let _environment = EnvVarRestore::set(key, "0"); + + let timeout_error = plugin_idle_timeout().unwrap_err(); + let heartbeat_error = plugin_heartbeat_interval().unwrap_err(); + + assert!(timeout_error.contains("must be greater than 0")); + assert!(heartbeat_error.contains("must be greater than 0")); +} + +#[test] +fn gateway_start_reports_an_uncreatable_runtime_directory() { + let dir = tempfile::tempdir().unwrap(); + let runtime_base = dir.path().join("runtime"); + let runtime = runtime_base.join("nemo-relay-plugin"); + std::fs::create_dir_all(&runtime_base).unwrap(); + std::fs::write(&runtime, "file").unwrap(); + let _lock = crate::test_support::ENV_TEST_LOCK + .lock() + .unwrap_or_else(|error| error.into_inner()); + let _runtime = EnvVarRestore::set("XDG_RUNTIME_DIR", runtime_base.to_str().unwrap()); + let _config = EnvVarRestore::set("XDG_CONFIG_HOME", dir.path().to_str().unwrap()); + let bind = unused_loopback_address(); + + let error = GatewaySpec::new(CodingAgent::Codex, bind) + .ensure() + .unwrap_err(); + + assert!(error.contains("failed to create"), "{error}"); + assert!(error.contains(&runtime.display().to_string()), "{error}"); + assert!(error.contains("inspect"), "{error}"); +} + +#[test] +fn gateway_start_reports_an_uncreatable_state_directory() { + let dir = tempfile::tempdir().unwrap(); + let runtime_base = dir.path().join("runtime"); + let config_base = dir.path().join("config"); + let _lock = crate::test_support::ENV_TEST_LOCK + .lock() + .unwrap_or_else(|error| error.into_inner()); + let _runtime = EnvVarRestore::set("XDG_RUNTIME_DIR", runtime_base.to_str().unwrap()); + let _config = EnvVarRestore::set("XDG_CONFIG_HOME", config_base.to_str().unwrap()); + let state = sidecar_state_dir().unwrap(); + std::fs::create_dir_all(state.parent().unwrap()).unwrap(); + std::fs::write(&state, "file").unwrap(); + let bind = unused_loopback_address(); + + let error = GatewaySpec::new(CodingAgent::Codex, bind) + .ensure() + .unwrap_err(); + + assert!(error.contains("failed to create"), "{error}"); + assert!(error.contains(&state.display().to_string()), "{error}"); +} + +#[test] +fn gateway_start_reports_an_unopenable_endpoint_lock() { + let dir = tempfile::tempdir().unwrap(); + let runtime_base = dir.path().join("runtime"); + let config_base = dir.path().join("config"); + let _lock = crate::test_support::ENV_TEST_LOCK + .lock() + .unwrap_or_else(|error| error.into_inner()); + let _runtime = EnvVarRestore::set("XDG_RUNTIME_DIR", runtime_base.to_str().unwrap()); + let _config = EnvVarRestore::set("XDG_CONFIG_HOME", config_base.to_str().unwrap()); + let state = sidecar_state_dir().unwrap(); + let bind = unused_loopback_address(); + let url = format!("http://{bind}"); + let endpoint_lock = sidecar_lock_path(&state, &url); + std::fs::create_dir_all(&endpoint_lock).unwrap(); + + let error = GatewaySpec::new(CodingAgent::Codex, bind) + .ensure() + .unwrap_err(); + + assert!(error.contains("failed to open sidecar lock"), "{error}"); + assert!( + error.contains(&endpoint_lock.display().to_string()), + "{error}" + ); +} diff --git a/docs/about-nemo-relay/release-notes/known-issues.mdx b/docs/about-nemo-relay/release-notes/known-issues.mdx index 7d1b64a4d..d04d6dc5f 100644 --- a/docs/about-nemo-relay/release-notes/known-issues.mdx +++ b/docs/about-nemo-relay/release-notes/known-issues.mdx @@ -9,6 +9,22 @@ SPDX-License-Identifier: Apache-2.0 */} This page lists current limitations and support notes for the release documentation set. +## NVIDIA NeMo Relay 0.6 + +- Codex can perform provider discovery and request `/models` before launching + required plugin MCP servers. That cold-start request can fail and be retried; + the captured turn and `/responses` traffic wait for verified Relay gateway + readiness. +- On Windows, a restrictive host Job Object can prevent the Codex plugin's + Relay sidecar from breaking away. When nested job assignment is supported, + the sidecar remains scoped to the host job and the shared gateway might not + remain available for the full 300-second idle reuse window. If Relay cannot + create or configure its cleanup job, bootstrap fails instead of starting + without descendant-cleanup guarantees. +- The Codex 0.143 plugin hook schema does not expose `SessionEnd`. Relay writes a + cumulative trajectory snapshot for every delivered `Stop`, so the final turn + snapshot is also the final session snapshot. + ## NVIDIA NeMo Relay 0.5 These notes apply to the NVIDIA NeMo Relay 0.5 release. @@ -19,8 +35,6 @@ These notes apply to the NVIDIA NeMo Relay 0.5 release. - The NeMo Relay CLI is experimental. Coding-agent observability support varies with host plugin, hook, and provider-routing capabilities. Hooks alone cannot produce complete LLM request and response spans. -- Complete first-request capture in Codex plugin mode depends on Codex firing - an installed hook before the first provider request. - Node.js 24 or newer is required for Node.js binding and package workflows. - OpenClaw support uses public hook-backed telemetry with partial security and optimization support. Security is limited to pre-tool conditional guardrails, diff --git a/docs/build-plugins/dynamic-plugins/grpc-worker/python/about.mdx b/docs/build-plugins/dynamic-plugins/grpc-worker/python/about.mdx index 6317330a5..1c6db7772 100644 --- a/docs/build-plugins/dynamic-plugins/grpc-worker/python/about.mdx +++ b/docs/build-plugins/dynamic-plugins/grpc-worker/python/about.mdx @@ -211,6 +211,14 @@ print(f"sha256:{sha256(artifact.read_bytes()).hexdigest()}") PY ``` +Relay requires the module portion of `load.entrypoint` to resolve directly +under `source.manifest_root` to exactly one Python source file: either +`module/path.py` or `module/path/__init__.py`. That canonical file must be the +integrity-checked `source.artifact`. Relay rejects ambiguous modules and custom +build-backend mappings, including `src/` layouts that cannot be derived from the +manifest. This prevents an unsigned sibling module from becoming the executed +entrypoint. + ## Register the Plugin Run the following commands from the project directory: @@ -227,6 +235,13 @@ the `source.manifest_root` project into it. Relay uses that recorded environment when it starts the worker. Do not start the worker directly: Relay supplies its worker socket, host socket, activation ID, and activation token. +Relay records a digest of the installed environment in a locally authenticated +marker bound to the integrity-checked entrypoint artifact. The marker detects +changes between provisioning and activation, but it is not a security boundary +against a process running as the same operating-system user, which can access +the same user-owned keys and files. Worker process isolation is not a security +sandbox. + ## Configure the Plugin After `plugins add` registers the worker, edit diff --git a/docs/nemo-relay-cli/about.mdx b/docs/nemo-relay-cli/about.mdx index 32211aba7..5412d3e15 100644 --- a/docs/nemo-relay-cli/about.mdx +++ b/docs/nemo-relay-cli/about.mdx @@ -49,7 +49,7 @@ controls. | Agent | Observability | Security | Optimization | Notes | | --- | --- | --- | --- | --- | | Claude Code | Yes | Yes | Partial | Pre-tool hook responses are supported. LLM optimization uses gateway-routed traffic; full coverage depends on loaded Claude Code hooks. | -| Codex | Yes | Yes | Partial | Hook forwarding and gateway-routed LLM optimization are supported after hooks are reviewed and activated. The missing session-end hook limits full coverage. | +| Codex | Yes | Yes | Partial | Persistent install verifies all 10 hooks in the supported schema. Each `Stop` finalizes a turn snapshot because the plugin schema does not expose `SessionEnd`. | | Hermes Agent | Yes | Yes | Partial | Hook forwarding, pre-tool guardrails, and Hermes API-request telemetry are supported. Optimization depends on Hermes shell and API-request hook coverage. | ## Guides diff --git a/docs/nemo-relay-cli/basic-usage.mdx b/docs/nemo-relay-cli/basic-usage.mdx index 674ebed8b..4686a2c84 100644 --- a/docs/nemo-relay-cli/basic-usage.mdx +++ b/docs/nemo-relay-cli/basic-usage.mdx @@ -113,7 +113,7 @@ nemo-relay uninstall codex ``` Refer to [Plugin Installation](/nemo-relay-cli/plugin-installation) for install -directories, host-specific behavior, and Codex lazy sidecar constraints. +directories, host-specific behavior, and Codex shared-sidecar lifecycle. ## Shared Configuration @@ -306,8 +306,8 @@ Generated hook bundles subscribe to the events needed for that mapping: | Agent | LLM lifecycle and correlation hooks | Scope, tool, and mark hooks | | --- | --- | --- | -| Claude Code | `UserPromptSubmit`, `AfterAgentResponse`, `AfterAgentThought`, `Stop` | `SessionStart`, `SessionEnd`, `SubagentStart`, `SubagentStop`, `PreToolUse`, `PostToolUse`, `PostToolUseFailure`, `Notification`, `PreCompact` | -| Codex | `UserPromptSubmit`, `AfterAgentResponse`, `AfterAgentThought`, `Stop` | `SessionStart`, `SessionEnd`, `SubagentStart`, `SubagentStop`, `PreToolUse`, `PostToolUse`, `PostToolUseFailure`, `Notification`, `PreCompact` | +| Claude Code | `UserPromptSubmit`, `Stop` | `SessionStart`, `SessionEnd`, `SubagentStart`, `SubagentStop`, `PreToolUse`, `PostToolUse`, `PostToolUseFailure`, `PermissionRequest`, `Notification`, `PreCompact`, `PostCompact` | +| Codex | `UserPromptSubmit`, `Stop` | `SessionStart`, `SubagentStart`, `SubagentStop`, `PreToolUse`, `PostToolUse`, `PermissionRequest`, `PreCompact`, `PostCompact` | | Hermes | `pre_api_request`, `post_api_request`, `api_request_error`, `pre_llm_call`, `post_llm_call` | `on_session_start`, `on_session_end`, `on_session_finalize`, `on_session_reset`, `subagent_start`, `subagent_stop`, `pre_tool_call`, `post_tool_call` | Hermes `pre_api_request`, `post_api_request`, and `api_request_error` hooks diff --git a/docs/nemo-relay-cli/codex.mdx b/docs/nemo-relay-cli/codex.mdx index 2446e1ff4..b7240f7f6 100644 --- a/docs/nemo-relay-cli/codex.mdx +++ b/docs/nemo-relay-cli/codex.mdx @@ -14,17 +14,21 @@ local gateway cannot observe provider traffic that never reaches the machine. ## Requirements -`codex-cli >= 0.129.0`. The gateway uses the `features.hooks` flag and the -`nemo-relay-openai` provider alias, both of which require this version. Earlier -versions either reject the provider override or do not recognize the hooks -feature flag. +`codex-cli >= 0.143.0`. Persistent installation requires exactly one discovered, +enabled, and trusted handler for all 10 events in the supported Codex hook schema: +`SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, +`PermissionRequest`, `SubagentStart`, `SubagentStop`, `Stop`, `PreCompact`, and +`PostCompact`. Persistent setup also requires the +`features.hooks` flag, plugin app-server metadata, and the `nemo-relay-openai` +provider alias. The installer checks the Codex version before it changes any +files. -As of Codex 0.129, Codex requires hooks to be manually reviewed and activated -before they run. Generated NeMo Relay hook configuration is not enough on its own -if Codex leaves those hooks inactive. Review and activate the installed or -injected hooks in Codex before expecting NeMo Relay events. This is being tracked -upstream as [openai/codex#21639](https://github.com/openai/codex/issues/21639). +Codex requires hooks to be trusted before they run. `nemo-relay install codex` +uses the Codex app-server API to trust and verify only the exact hooks owned by +`nemo-relay-plugin@nemo-relay-local`. Source-marketplace or manually installed +hooks can still require review. This behavior is tracked upstream as +[openai/codex#21639](https://github.com/openai/codex/issues/21639). @@ -84,9 +88,74 @@ The installer creates a local marketplace, installs existing `nemo-relay` binary on `PATH` but does not install a plugin-local Relay binary. -Codex plugin mode uses hook-supervised on-demand startup only. It does not install a -wrapper, user-level daemon, launch agent, system user service, scheduled -task, login item, or persistent supervisor. +The required plugin MCP process is a lightweight lifecycle client. It starts or +reuses a detached Rust `nemo-relay --bind 127.0.0.1:47632` sidecar and completes +MCP initialization only after `/healthz` confirms the Relay identity, version, +bootstrap protocol, and effective persistent configuration. The compatibility +check uses a one-way fingerprint that includes relevant environment values; it +does not return credentials or configuration secrets. Dynamic plugin manifests, +artifacts, and signatures included in this identity must be regular files. The +complete activation snapshot, including adjacent runtime files and a copied +managed Python environment, is limited to 100,000 filesystem entries and 512 +MiB in total, with a maximum directory traversal depth of 128. When startup +reports an activation snapshot budget error, remove unrelated files from the +manifest or load-target directory, flatten deeply nested directories, or reduce +the managed Python environment before retrying. Concurrent Codex processes +share the sidecar. Each MCP client sends a heartbeat while its stdio connection +is open, and the sidecar exits after 300 seconds without activity by default. No +wrapper, launch agent, system user service, scheduled task, login item, or +persistent supervisor is installed. + +On Windows, Relay requests Job Object breakaway only when the host job permits +it. If the host uses a restrictive Job Object but permits nested jobs, Relay +keeps the sidecar scoped to that host job and retains a nested cleanup job for +the sidecar process tree. The sidecar cannot outlive the host job, so the usual +300-second idle reuse window can end early. If Relay cannot create or configure +the cleanup job, or the host rejects nested job assignment, persistent bootstrap +fails with an actionable process-tree cleanup error instead of starting without +that guarantee. + +Persistent plugin mode loads system and user Relay configuration only. It does +not load a project's `.nemo-relay` files. The sidecar starts in the user Relay +configuration directory, so relative exporter paths are deterministic. Use a +transparent `nemo-relay run` when project-specific configuration is required. + +The generated MCP entry forwards variable names, never values, for provider +credentials, Relay runtime settings, common OpenTelemetry, AWS, proxy, and +certificate settings. It also forwards approved `NEMO_RELAY_`, `OTEL_`, and +`AWS_` names that exist during installation and credential variable names +referenced by user observability configuration. Rerun +`nemo-relay install codex --force` when doctor reports a missing forwarded +variable. + +Plugin-owned hook commands pin `http://127.0.0.1:47632` explicitly, so an +ambient `NEMO_RELAY_PLUGIN_GATEWAY_URL` cannot split hook traffic from the +required MCP-managed gateway. + +If user configuration, forwarded credentials, or the Relay version changes +while an old sidecar is still running, the new MCP client reports an actionable +compatibility conflict instead of silently reusing the wrong process. Running +`nemo-relay install codex --force` retires a sidecar owned by the prior install +through Relay's private shutdown handshake before it refreshes the plugin. +Running ordinary install again against an existing fenced Codex installation +does not replace files; use `--force` for the fenced replacement transaction. + +An existing install without MCP generation fencing cannot be retired safely, +even when Codex reports that the plugin is no longer registered, because an +already-running MCP process can outlive registration. If upgrade or uninstall +reports a missing MCP generation marker, close all Codex clients and standalone +`nemo-relay mcp` processes, then enter: + +```bash +codex plugin remove nemo-relay-plugin@nemo-relay-local +codex plugin marketplace remove nemo-relay-local +``` + +Remove `codex-marketplace` and `codex.json` from the install directory named in +the error, then retry `nemo-relay install codex --force` to install a fenced +generation. If removal was the original goal, run `nemo-relay uninstall codex` +immediately afterward; the fenced reinstall lets Relay remove provider and hook +trust state transactionally. To check for the installed plugin, enter: @@ -101,13 +170,12 @@ nemo-relay uninstall codex ``` Refer to [Plugin Installation](/nemo-relay-cli/plugin-installation) for install -directories, first-request capture limits, rollback behavior, and source +directories, shared-sidecar behavior, rollback behavior, and source marketplace notes. -## Shared Config +## Configure Transparent Runs -Create `.nemo-relay/config.toml` for project defaults or -`~/.config/nemo-relay/config.toml` for user defaults: +Create `.nemo-relay/config.toml` for project defaults: ```toml [upstream] @@ -117,7 +185,7 @@ openai_base_url = "https://api.openai.com/v1" command = "codex" ``` -Then configure observability with `nemo-relay plugins edit --project` or +Configure project observability with `nemo-relay plugins edit --project` or `.nemo-relay/plugins.toml`: ```toml @@ -132,8 +200,35 @@ enabled = true output_directory = ".nemo-relay/atif" ``` -Run `nemo-relay run --agent codex` to use the configured command and plugin -config. User config takes priority over project and system config. +Run `nemo-relay run --agent codex` to use project-specific configuration. The +ATIF files from this example are written under the project at +`.nemo-relay/atif`. + +## Configure the Persistent Plugin + +Create `~/.config/nemo-relay/config.toml`, or +`$XDG_CONFIG_HOME/nemo-relay/config.toml` when `XDG_CONFIG_HOME` is set, for +persistent provider defaults. Use `nemo-relay plugins edit` without +`--project` to write user-scoped observability configuration. For example, set +the ATIF output directory to `atif`: + +```toml +version = 1 + +[[components]] +kind = "observability" +enabled = true + +[components.config.atif] +enabled = true +output_directory = "atif" +``` + +The persistent sidecar deliberately ignores project layers, merges only system +and user configuration, and starts in the user Relay configuration directory. +The relative output directory in this example therefore resolves to +`$XDG_CONFIG_HOME/nemo-relay/atif`, or `~/.config/nemo-relay/atif` when +`XDG_CONFIG_HOME` is not set. ## Standalone Gateway @@ -179,17 +274,21 @@ traffic to pass through the gateway. ## Captured Events -Generated Codex hooks include `SessionStart`, `SessionEnd`, `SubagentStart`, -`SubagentStop`, `PreToolUse`, `PostToolUse`, `PostToolUseFailure`, -`Notification`, and `PreCompact` for scope, tool, and mark events. -`UserPromptSubmit`, `AfterAgentResponse`, `AfterAgentThought`, and `Stop` are -retained as private LLM correlation hints and are not emitted as standalone -NeMo Relay events. +The required generated set includes `SessionStart`, `UserPromptSubmit`, +`PreToolUse`, `PostToolUse`, `PermissionRequest`, `SubagentStart`, +`SubagentStop`, `Stop`, `PreCompact`, and `PostCompact`. Relay requires the Codex +app-server to discover exactly one enabled, trusted handler for every generated +event. `PostToolUseFailure`, `Notification`, and `SessionEnd` are not in the +Codex 0.143 plugin hook schema, so Relay does not generate undiscoverable handlers +for them. Relay maps delivered events to agent, turn, subagent, tool, and mark +lifecycle events; prompt and stop payloads also provide LLM correlation context. The transparent wrapper passes hook entries as Codex CLI config overrides and sets `features.hooks=true` for that launched process. Persistent install writes -`.codex/config.toml` with `features.hooks = true` and merges generated hook -entries into `.codex/hooks.json`. +`.codex/config.toml` with `features.hooks = true`, but the installed plugin's +`hooks/hooks.json` is the sole persistent Relay hook source. During upgrades, +the installer removes legacy Relay groups from `.codex/hooks.json` while +preserving unrelated hooks and backups. ## Smoke Test @@ -208,20 +307,27 @@ an empty JSON object. ## Verify Export End the Codex session and confirm Agent Trajectory Interchange Format (ATIF) -exists: +exists at the path for the selected workflow. + +For a transparent project run, enter: ```bash ls .nemo-relay/atif ``` -The gateway writes `.atif.json` after every conversation turn for -Codex sessions (Codex's hook surface has no `SessionEnd`-equivalent event, so -the gateway uses each per-turn `Stop` hook to snapshot the trajectory; the file -grows cumulatively across turns and the final write reflects the full session). -For agents that do emit a session-end hook, the same file is written once on -session close. If the file is missing, confirm `features.hooks = true`, hook -config loading, and that `plugins.toml` enables the ATIF exporter with a -writable `output_directory`. +For a persistent plugin configured with the relative user-scoped path above, +enter: + +```bash +ls "${XDG_CONFIG_HOME:-$HOME/.config}/nemo-relay/atif" +``` + +The Codex plugin schema does not expose `SessionEnd`, so the gateway uses each +per-turn `Stop` hook to write `.atif.json`. The file grows cumulatively +across turns, so the final `Stop` write reflects the full session. If the file is +missing, confirm `features.hooks = true`, hook config +loading, and that `plugins.toml` enables the ATIF exporter with a writable +`output_directory`. ## Troubleshoot LLM Lifecycle @@ -234,3 +340,10 @@ If LLM spans exist but attach to the session instead of a subagent, pass `x-nemo-relay-subagent-id` on gateway requests or include shared `conversation_id`, `generation_id`, or `request_id` values in both hook payloads and provider requests. + +## Cold-Start Limitation + +Codex can request `/models` while it is discovering the provider, before it +launches plugin MCP servers. That pre-MCP request can fail and be retried. The +required MCP server still blocks the captured turn and `/responses` request +until the shared Relay gateway is ready. diff --git a/docs/nemo-relay-cli/plugin-installation.mdx b/docs/nemo-relay-cli/plugin-installation.mdx index 920887fee..6a568faf9 100644 --- a/docs/nemo-relay-cli/plugin-installation.mdx +++ b/docs/nemo-relay-cli/plugin-installation.mdx @@ -27,6 +27,9 @@ The selected host CLI must also be available: - `claude` for Claude Code plugin installation. - `codex` for Codex plugin installation. +Codex plugin installation requires `codex-cli >= 0.143.0`. The installer checks +the version before modifying plugin or Codex configuration. + ## Install Host Plugin Run the command for the host plugin that you want to install: @@ -64,6 +67,11 @@ default directory is platform-specific: | Linux | `${XDG_DATA_HOME:-~/.local/share}/nemo-relay/plugins` | | Windows | `%LOCALAPPDATA%\nemo-relay\plugins` | +Install, force-install, rollback, and uninstall operations are serialized per +user and host, including operations that name different install directories. +If another operation is active, Relay waits briefly and then returns an +actionable timeout instead of changing host-global plugin state concurrently. + ## What Install Changes `nemo-relay install` writes a local marketplace named `nemo-relay-local`, then @@ -76,22 +84,56 @@ existing Claude authentication and model settings and backs them up only when it adds the Relay provider route. For Codex, install registers the local Codex marketplace, installs -`nemo-relay-plugin@nemo-relay-local`, enables Codex hooks, merges generated hook -entries, and configures the `nemo-relay-openai` provider alias at -`http://127.0.0.1:47632`. - -Codex plugin mode is hook-supervised on-demand startup only. It does not install a -Codex wrapper, user-level daemon, launch agent, system user service, -scheduled task, login item, or persistent supervisor. The sidecar starts when an -installed Codex hook runs, reuses an already healthy sidecar when one exists, -and exits after its idle timeout. - - -The plugin captures the first provider request only when Codex fires an -installed hook before that request. If a Codex version calls the provider -before any hook, the plugin cannot guarantee first-request capture under -hook-only lazy startup. - +`nemo-relay-plugin@nemo-relay-local`, enables Codex hooks, and configures the +`nemo-relay-openai` provider alias at `http://127.0.0.1:47632`. The plugin's +`hooks/hooks.json` is the sole persistent Relay hook source. The installer uses +the Codex app-server API to select hooks by plugin ID, exact canonical command, +and event definition. It never trusts unrelated user, project, or plugin hooks. +If Codex does not discover exactly one enabled and trusted handler for every +generated event—`SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, +`PermissionRequest`, `SubagentStart`, `SubagentStop`, `Stop`, `PreCompact`, and +`PostCompact`—installation restores the previous Codex config, legacy hooks, +Relay backup files, and every original targeted trust entry. These 10 events are +the complete supported Codex 0.143 plugin hook schema; Relay does not generate +undiscoverable `PostToolUseFailure`, `Notification`, or `SessionEnd` handlers. +Upgrade removes legacy Relay groups from +`~/.codex/hooks.json` while preserving unrelated hooks. + +Start a new Codex CLI process after installation. Restart the Codex desktop app +if it was already running so it reloads the provider and hook configuration. + +Codex plugin mode uses a required `nemo-relay mcp` lifecycle client. The client +starts or reuses a detached native sidecar on `127.0.0.1:47632`, verifies Relay +identity, version, protocol readiness, and effective user-level configuration, +and heartbeats it every 30 seconds. Concurrent +Codex processes share the gateway. After the final MCP client closes, the +sidecar exits after 300 idle seconds by default. If the gateway exits while MCP +stdio is open, the client performs one coordinated restart and fails if +recovery does not succeed. A foreign process on the configured port is rejected +with an actionable conflict instead of being accepted because it returned HTTP +200. + +On Windows, Relay requests Job Object breakaway only when the host job permits +it. If the host applies a restrictive job but permits nested jobs, Relay keeps +the sidecar scoped to that host job and retains a nested cleanup job for its +process tree; the sidecar cannot outlive the host job. If Relay cannot create or +configure the cleanup job, or the host rejects nested assignment, persistent +bootstrap fails actionably instead of starting without process-tree cleanup +guarantees. + +The compatibility fingerprint covers resolved provider, exporter, dynamic +plugin, and relevant environment settings without exposing their values in the +health response. `nemo-relay install codex --force` uses a private ownership +token to stop an existing installer-owned sidecar before refreshing the plugin; +it does not terminate an unrelated listener. + +Persistent mode resolves only system and user Relay configuration and starts in +the user configuration directory. Project `.nemo-relay` layers remain available +through transparent `nemo-relay run` invocations. The generated MCP manifest +forwards environment variable names for provider credentials, Relay runtime, +OpenTelemetry, AWS, proxies, certificates, approved prefixes, and credential +variables referenced by user observability configuration. It never stores +their values. ## Diagnose @@ -112,8 +154,14 @@ nemo-relay doctor --plugin all `nemo-relay doctor` includes every persistent host-plugin installation found in the default platform install directory. It checks the generated marketplace and plugin files, Relay binary and hook support, host registration, provider -routing, hooks, and lazy-sidecar assumptions. A stopped Codex lazy sidecar is -informational; hooks start it on first use. +routing, hooks, and sidecar readiness assumptions. For Codex, doctor also asks the +Codex app-server whether each exact Relay-generated hook is trusted and +enabled. JSON output includes `checks.codex_hooks_trusted` and a +`codex_hook_trust` object that groups trusted, untrusted, modified, disabled, +missing, or duplicated required hooks. A stopped Codex sidecar is informational; +the required plugin MCP starts it before the captured turn. Doctor also reports +newly required forwarded environment names and recommends +`nemo-relay install codex --force`. Use the focused plugin doctor when diagnosing one host or an installation that uses a custom directory: @@ -143,10 +191,11 @@ nemo-relay uninstall codex nemo-relay uninstall all ``` -Uninstall removes the generated host plugin registration and marketplace entry, -restores Claude Code provider routing from the Relay backup, and removes -generated Codex hook/provider configuration, while preserving unrelated user -configuration. +Uninstall removes Codex provider configuration and trust for the exact plugin +hooks before unregistering the plugin, while Codex can still report its hook +metadata. It then removes the host registration and marketplace. Claude Code +provider routing is restored from the Relay backup. Unrelated user hooks and +configuration remain unchanged. ## Source Marketplace Discovery @@ -159,6 +208,5 @@ validation: Those manifests are useful when validating host plugin metadata from a source checkout. For end-user setup, use `nemo-relay install `. It generates the local marketplace, registers the host plugin, and performs the required -provider and hook setup. Avoid keeping both a source-installed -plugin and a generated install active for the same host because both can forward -the same hook payload. +provider and hook setup. Source manifests do not provide Codex's complete +provider, environment-forwarding, and verified-trust workflow. diff --git a/integrations/coding-agents/README.md b/integrations/coding-agents/README.md index 9a9c09a0c..1096ae96b 100644 --- a/integrations/coding-agents/README.md +++ b/integrations/coding-agents/README.md @@ -28,9 +28,10 @@ environment variables, or shared TOML config. - `codex/` is a Codex plugin package. `nemo-relay install codex` creates the marketplace, installs the plugin, enables `features.hooks = true`, and configures a local `nemo-relay-openai` provider alias. Codex plugin delivery - uses hook-supervised lazy sidecar startup only, with no wrapper, user-level daemon, - login item, launchd agent, systemd user service, scheduled task, or persistent - supervisor. + uses required native `nemo-relay mcp` lifecycle clients that share one + Rust gateway, subject to the Windows Job Object lifetime caveat below, with no + wrapper, login item, launchd agent, systemd user service, scheduled task, or + persistent supervisor. - Hermes does not require a static bundle in this directory. The setup wizard (`nemo-relay config`) merges hook commands into `.hermes/config.yaml` when hermes is selected. @@ -76,11 +77,23 @@ required. Claude Code can start the sidecar from plugin hooks or helper commands and route model traffic by setting `ANTHROPIC_BASE_URL` to the sidecar URL. -Codex does not use a daemon in plugin mode. The installed Codex hooks call the -`nemo-relay plugin-shim hook codex` command. The shim then checks `/healthz`, starts -the local `nemo-relay` sidecar, if needed, waits briefly for readiness, and then -forwards the hook payload. Codex model traffic is routed through the stable -provider alias at `http://127.0.0.1:47632`. +Codex's required plugin MCP entry starts `nemo-relay mcp`, a lightweight client +that starts or reuses a native `nemo-relay --bind 127.0.0.1:47632` sidecar. Relay +detaches the sidecar when host policy permits. A restrictive Windows Job Object +keeps the sidecar scoped to that host job instead of failing bootstrap. +MCP initialization waits for Relay identity, version, and bootstrap-protocol +readiness. Concurrent Codex processes share the gateway and heartbeat it while +their MCP stdio connections remain open; the gateway exits after the final +client's idle timeout. Plugin-owned hooks call the canonically resolved +`nemo-relay plugin-shim hook codex` command, and model traffic uses the same +stable provider alias. + +Persistent Codex mode loads system and user Relay configuration only and starts +the sidecar from the user configuration directory. Relative exporter paths are +therefore stable across projects. The generated MCP manifest forwards approved +provider, Relay, OpenTelemetry, AWS, proxy, certificate, and config-referenced +credential environment names without storing their values. Use transparent +`nemo-relay run` for project-specific configuration. Install the local host marketplaces with: @@ -106,9 +119,9 @@ codex plugin add nemo-relay-plugin@nemo-relay That path relies on `nemo-relay` being available on `PATH`; source plugin hooks invoke `nemo-relay plugin-shim hook codex` directly. -Use the source marketplace path for discovery or manifest validation. Remove -the source-installed Codex plugin before running `nemo-relay install codex`; -keeping both active can forward the same Codex hook twice. +Use the source marketplace path for discovery or manifest validation. Use +`nemo-relay install codex` for complete provider routing, environment +forwarding, and verified plugin-hook trust. Claude Code users can add this repository as a marketplace the same way: @@ -132,6 +145,9 @@ project `.nemo-relay/config.toml`, then `$XDG_CONFIG_HOME/nemo-relay/config.toml` or `~/.config/nemo-relay/config.toml`. +That layering applies to transparent runs. Persistent Codex plugin mode skips +the project layer and merges only system and user configuration. + ```toml [agents.codex] command = "codex" @@ -176,8 +192,9 @@ the same hook command reaches the ephemeral per-run gateway; hermes hooks fall back to an embedded `--gateway-url` when running outside the wrapper. Claude Code and Codex plugin hooks call `nemo-relay plugin-shim hook `. -The plugin shim ensures the local sidecar is reachable, then forwards the hook -payload to the plugin sidecar endpoint. +For Codex, the installed plugin file is the sole persistent Relay hook source; +installation does not add Relay groups to `~/.codex/hooks.json`. The shim +forwards each canonical payload to the verified shared sidecar. Since hook forwarding fails open by default, gateway or sidecar outages do not block the coding agent. The hook command exits successfully after logging the 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..f3ff39455 --- /dev/null +++ b/integrations/coding-agents/codex/.mcp.json @@ -0,0 +1,62 @@ +{ + "nemo-relay": { + "command": "nemo-relay", + "args": [ + "mcp" + ], + "env": { + "NEMO_RELAY_GATEWAY_BIND": "127.0.0.1:47632" + }, + "env_vars": [ + "ANTHROPIC_API_KEY", + "APPDATA", + "AWS_ACCESS_KEY_ID", + "AWS_ALLOW_HTTP", + "AWS_CONFIG_FILE", + "AWS_DEFAULT_REGION", + "AWS_ENDPOINT_URL", + "AWS_PROFILE", + "AWS_REGION", + "AWS_SECRET_ACCESS_KEY", + "AWS_SESSION_TOKEN", + "AWS_SHARED_CREDENTIALS_FILE", + "HOME", + "HTTPS_PROXY", + "HTTP_PROXY", + "LOCALAPPDATA", + "NEMO_RELAY_ANTHROPIC_BASE_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", + "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", + "http_proxy", + "https_proxy", + "no_proxy" + ], + "required": true, + "startup_timeout_sec": 20 + } +} diff --git a/integrations/coding-agents/codex/README.md b/integrations/coding-agents/codex/README.md index 028608207..8fb7344af 100644 --- a/integrations/coding-agents/codex/README.md +++ b/integrations/coding-agents/codex/README.md @@ -13,40 +13,109 @@ supported only when they run locally and honor the same hook/plugin config and provider routing. Cloud or remote Codex tasks are partial or unsupported for local gateway LLM capture. -Requires `codex-cli >= 0.129.0` (introduced the `features.hooks` flag and the -provider alias surface the gateway relies on). +Requires `codex-cli >= 0.143.0`, including the complete required lifecycle-hook +set, plugin app-server metadata, `features.hooks`, and provider alias surfaces +used by the installer. ## Files - `.codex-plugin/plugin.json` describes the Codex plugin package. +- `.mcp.json` starts the native `nemo-relay mcp` lifecycle client and requires + successful gateway initialization. - `hooks/hooks.json` contains Codex hook entries that run `nemo-relay plugin-shim hook codex`. - `nemo-relay install codex` creates the local marketplace, installs the plugin, - and persists Codex hook and provider configuration using `nemo-relay` from - `PATH`. + and persists Codex provider and exact plugin-hook trust using `nemo-relay` + from `PATH`. ## Captured Events -With `codex-cli >= 0.129.0`, the minimum supported installed hooks are -`SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, -`PermissionRequest`, `Stop`, `PreCompact`, and `PostCompact`. +With `codex-cli >= 0.143.0`, persistent installation requires `SessionStart`, +`UserPromptSubmit`, `PreToolUse`, `PostToolUse`, `PermissionRequest`, +`SubagentStart`, `SubagentStop`, `Stop`, `PreCompact`, and `PostCompact`. Relay +requires exactly one enabled and trusted app-server handler for every generated +event and forwards delivered hooks as scope, tool, mark, or private LLM +correlation events. `PostToolUseFailure`, `Notification`, and `SessionEnd` are not +in the Codex 0.143 plugin hook schema and are not generated. + +Each delivered `Stop` closes the active turn and writes a cumulative ATIF +snapshot. Because the plugin schema does not expose `SessionEnd`, the final +`Stop` is the final session snapshot. + +Transparent setup injects hooks with CLI config overrides. Persistent setup +writes `features.hooks = true` in `.codex/config.toml`, configures the +`nemo-relay-openai` provider alias, and uses this plugin's `hooks/hooks.json` as +the sole persistent Relay hook source. It does not add Relay groups to +`.codex/hooks.json`. -The hook template also documents events used by newer or broader host hook -surfaces, including `SessionEnd`, `PostToolUseFailure`, `SubagentStart`, -`SubagentStop`, and `Notification`. Relay forwards any delivered supported hook -as scope, tool, mark, or private LLM correlation events, but the v1 plugin -manifest does not depend on Codex exposing those broader events. +Persistent installation opens the stable Codex app-server interface and +selects only hooks whose source is `plugin`, plugin ID is +`nemo-relay-plugin@nemo-relay-local`, and command exactly matches the generated +canonical Relay shim command. It requires exactly one handler for each event +in the complete 10-event supported set listed above. + +Unrelated user, project, and plugin hooks are never trusted. If installation +fails after a trust write, it restores every targeted hook's prior trusted, +modified, disabled, or absent state together with Codex config and backups. +Upgrade removes legacy Relay user-hook groups while preserving unrelated hooks. + +Codex plugin mode starts the native `nemo-relay mcp` subcommand through the +plugin MCP configuration. That Rust process is a lightweight lifecycle client: +it starts or reuses a detached native `nemo-relay --bind 127.0.0.1:47632` +sidecar, rejects foreign listeners, and completes the MCP handshake only after +Relay identity, version, bootstrap protocol, and effective persistent +configuration are verified. Compatibility uses a one-way fingerprint of the +resolved settings and relevant environment values without exposing secrets. +The complete dynamic plugin activation snapshot, including adjacent runtime +files and a copied managed Python environment, is limited to 100,000 filesystem +entries and 512 MiB in total, with a maximum directory traversal depth of 128. +If startup reports an activation snapshot budget error, remove unrelated files +from the manifest or load-target directory, flatten deeply nested directories, +or reduce the managed Python environment before retrying. Concurrent Codex +processes share the gateway and heartbeat it every 30 seconds. The sidecar +remains available for 300 idle seconds after the final client closes. If it dies +while MCP remains open, one coordinated restart is attempted. The MCP server +advertises no tools. + +On Windows, Relay requests Job Object breakaway only when the host job permits +it. Under a restrictive Job Object that permits nested jobs, Relay keeps the +sidecar scoped to the host job and retains a nested cleanup job for the sidecar +process tree. The sidecar cannot outlive the host job, so the 300-second idle +reuse window can end early. If Relay cannot create or configure the cleanup job, +or the host rejects nested assignment, persistent bootstrap fails actionably +instead of running without process-tree cleanup guarantees. + +Persistent mode loads only system and user Relay configuration and starts from +the user configuration directory. Project `.nemo-relay` layers remain specific +to transparent `nemo-relay run` invocations. The MCP manifest forwards approved +provider, Relay, OpenTelemetry, AWS, proxy, certificate, and config-referenced +credential variable names without storing values. + +Plugin-owned hook commands pin `http://127.0.0.1:47632` explicitly, so an +ambient `NEMO_RELAY_PLUGIN_GATEWAY_URL` cannot split hook traffic from the +required MCP-managed gateway. + +If the Relay version, user configuration, or forwarded credentials change, an +MCP client refuses to reuse the incompatible sidecar. `nemo-relay install codex +--force` retires an installer-owned sidecar through its private shutdown token +before refreshing the plugin. + +An existing install without MCP generation fencing cannot be retired safely, +even when Codex reports that the plugin is no longer registered, because an +already-running MCP process can outlive registration. If upgrade or uninstall +reports a missing MCP generation marker, close every Codex client and +standalone `nemo-relay mcp` process, then run: -Transparent setup injects these hooks with CLI config overrides. Plugin setup -does not install hooks from the package template directly. It writes -`features.hooks = true` in `.codex/config.toml`, configures the -`nemo-relay-openai` provider alias, and merges hook shim entries into -`.codex/hooks.json`. +```bash +codex plugin remove nemo-relay-plugin@nemo-relay-local +codex plugin marketplace remove nemo-relay-local +``` -Codex plugin mode uses hook-supervised on-demand startup only. It does not install a -user-level daemon, launchd agent, systemd user service, scheduled task, login -item, wrapper, or persistent supervisor. The sidecar starts only when a Codex -hook invokes `nemo-relay plugin-shim hook codex`. +Remove `codex-marketplace` and `codex.json` from the install directory named in +the error, then retry `nemo-relay install codex --force` to install a fenced +generation. If removal was the original goal, run `nemo-relay uninstall codex` +immediately afterward; the fenced reinstall lets Relay remove provider and hook +trust state transactionally. ## Transparent Setup @@ -73,10 +142,9 @@ nemo-relay run \ -- codex ``` -## Shared Config +## Configure Transparent Runs -Use `.nemo-relay/config.toml` for project defaults or -`~/.config/nemo-relay/config.toml` for user defaults: +Use `.nemo-relay/config.toml` for project defaults: ```toml [agents.codex] @@ -104,6 +172,32 @@ Then run: nemo-relay run --agent codex ``` +This example writes ATIF files under the project at `.nemo-relay/atif`. + +## Configure the Persistent Plugin + +Use `~/.config/nemo-relay/config.toml`, or +`$XDG_CONFIG_HOME/nemo-relay/config.toml` when `XDG_CONFIG_HOME` is set, for +persistent provider defaults. Run `nemo-relay plugins edit` without +`--project` to write user-scoped observability configuration. For example: + +```toml +version = 1 + +[[components]] +kind = "observability" +enabled = true + +[components.config.atif] +enabled = true +output_directory = "atif" +``` + +Persistent mode ignores project layers and starts the sidecar in the user Relay +configuration directory. The relative path above resolves to +`$XDG_CONFIG_HOME/nemo-relay/atif`, or `~/.config/nemo-relay/atif` when +`XDG_CONFIG_HOME` is not set. + ## Standalone Gateway Use the long-running gateway only when you do not want to launch Codex through @@ -141,13 +235,19 @@ for context. ## Verify -Run a Codex session that starts, uses one simple tool, and ends. Confirm that -ATIF was written: +Run a Codex session that starts, uses one simple tool, and ends. For a +transparent project run, confirm that ATIF was written: ```bash ls .nemo-relay/atif ``` +For the persistent user-scoped configuration above, enter: + +```bash +ls "${XDG_CONFIG_HOME:-$HOME/.config}/nemo-relay/atif" +``` + For a direct endpoint smoke test against a manually started gateway: ```bash @@ -174,9 +274,10 @@ nemo-relay install codex `nemo-relay install codex` writes a local Codex marketplace, registers `nemo-relay-plugin`, enables Codex hooks, and configures the -`nemo-relay-openai` provider alias. Codex sidecar lifecycle remains -hook-supervised on-demand startup only; the installer does not create a wrapper or -daemon. +`nemo-relay-openai` provider alias. It writes a required MCP server entry that +invokes the resolved native `nemo-relay` binary. Installation automatically +trusts the exact plugin-owned hook definitions through `codex app-server` and +rolls back files and original trust state if activation cannot be verified. The install command requires `nemo-relay` to be available on `PATH`. It does not require launching Codex through the `nemo-relay` wrapper and does not install a @@ -193,16 +294,16 @@ That path reads `.agents/plugins/marketplace.json` from the repository and installs this Codex plugin from `integrations/coding-agents/codex`. Source hooks invoke `nemo-relay plugin-shim hook codex` directly. -Treat the source marketplace path as discovery or manifest validation. For the -complete provider and generated-hook setup, remove the source-installed plugin -first and then run `nemo-relay install codex`. Keeping both the source plugin -and the generated install active can forward the same Codex hook twice. +Treat the source marketplace path as discovery or manifest validation. Use +`nemo-relay install codex` for the complete provider, environment-forwarding, +and verified-trust setup. Package or unpack the plugin so the plugin root contains: ```text nemo-relay-plugin/ .codex-plugin/plugin.json + .mcp.json hooks/hooks.json ``` @@ -249,11 +350,8 @@ codex plugin marketplace add "$MARKETPLACE_ROOT" codex plugin add nemo-relay-plugin@nemo-relay-local ``` -For end-to-end installation, we recommend using`nemo-relay install codex`; it performs the -marketplace registration and the persistent Codex provider/hook setup together. -If you used the manual source marketplace commands above, remove that plugin -before running the full installer so source hook templates and generated -persistent hooks do not both forward the same event. +For end-to-end installation, use `nemo-relay install codex`; it performs the +marketplace registration and persistent provider/plugin-hook setup together. The installer writes a provider alias like: @@ -274,16 +372,23 @@ Run read-only plugin checks: nemo-relay doctor --plugin codex ``` +Doctor reports the generated MCP server, native `nemo-relay mcp` support, +plugin hook installation, environment forwarding, and live Codex trust state. +In JSON mode, inspect `checks.codex_hooks_trusted` and `codex_hook_trust` for +untrusted, modified, disabled, or missing required hook entries. + Start a normal Codex session: ```bash codex ``` -The installed hooks start the Relay sidecar lazily on -`http://127.0.0.1:47632`, and the Codex provider alias routes model traffic -through that sidecar. No launchd agent, systemd user service, scheduled task, -login item, wrapper, or persistent supervisor is installed. +Start a new CLI process after install, or restart the Codex desktop app if it +was already open, so the provider selection and hooks are reloaded. + +The required plugin MCP server starts or reuses the shared native Relay gateway +on `http://127.0.0.1:47632` before Codex begins the captured turn, and the +provider alias routes model traffic through it. To upgrade, replace the plugin directory contents with the new package for the same host, keep the same `MARKETPLACE_ROOT`, refresh the local marketplace @@ -297,14 +402,16 @@ codex plugin add nemo-relay-plugin@nemo-relay-local nemo-relay install codex ``` -To uninstall, remove NeMo Relay's Codex config and hook entries, remove the -marketplace registration, and remove the generated marketplace directory: +To uninstall, remove NeMo Relay's Codex config and exact plugin-hook trust, +remove the marketplace registration, and remove the generated marketplace +directory: ```bash nemo-relay uninstall codex ``` -Full first-request LLM capture depends on Codex firing one of the installed -hooks, especially `SessionStart` or `UserPromptSubmit`, before its first model -provider request. If a Codex version sends the provider request first, the first -request may fail or may not be captured until the next hook starts Relay. +Codex can perform provider discovery before it launches plugin MCP servers. A +cold start can therefore produce transient `/models` connection failures that +Codex retries. Because the MCP server is required, Codex does not begin the +captured turn or send its `/responses` request until the native Relay gateway is +ready; if startup fails, the turn fails instead of silently bypassing Relay. diff --git a/integrations/coding-agents/codex/hooks/hooks.json b/integrations/coding-agents/codex/hooks/hooks.json index 43a79c2be..d4048c37a 100644 --- a/integrations/coding-agents/codex/hooks/hooks.json +++ b/integrations/coding-agents/codex/hooks/hooks.json @@ -1,5 +1,5 @@ { - "SPDX-License-Identifier": "Apache-2.0", + "description": "SPDX-License-Identifier: Apache-2.0", "hooks": { "SessionStart": [ { @@ -47,18 +47,6 @@ ] } ], - "PostToolUseFailure": [ - { - "matcher": "*", - "hooks": [ - { - "type": "command", - "command": "nemo-relay plugin-shim hook codex", - "timeout": 30 - } - ] - } - ], "PermissionRequest": [ { "matcher": "*", @@ -93,17 +81,6 @@ ] } ], - "Notification": [ - { - "hooks": [ - { - "type": "command", - "command": "nemo-relay plugin-shim hook codex", - "timeout": 30 - } - ] - } - ], "Stop": [ { "hooks": [ @@ -136,17 +113,6 @@ } ] } - ], - "SessionEnd": [ - { - "hooks": [ - { - "type": "command", - "command": "nemo-relay plugin-shim hook codex", - "timeout": 30 - } - ] - } ] } } diff --git a/justfile b/justfile index b7666532d..d26d35f43 100644 --- a/justfile +++ b/justfile @@ -1056,6 +1056,10 @@ clean: examples/rust-native-plugin/target \ target +# Opt-in: requires codex-cli 0.143+ and is intentionally outside test-rust/CI. +test-codex-plugin-e2e: + ./scripts/test-codex-plugin-e2e.sh + # --set [output_dir=] [ci=true|false] test-rust: #!/usr/bin/env bash @@ -1238,8 +1242,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 @@ -1252,6 +1258,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/test-codex-plugin-e2e.sh b/scripts/test-codex-plugin-e2e.sh new file mode 100755 index 000000000..bcd64b228 --- /dev/null +++ b/scripts/test-codex-plugin-e2e.sh @@ -0,0 +1,693 @@ +#!/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; Codex plugin E2E requires codex-cli 0.143+" + exit 0 +fi + +codex_version="$(codex --version 2>/dev/null || true)" +if ! python3 - "$codex_version" <<'PY' +import re +import sys + +match = re.search(r"(\d+)\.(\d+)\.(\d+)", sys.argv[1]) +raise SystemExit(0 if match and tuple(map(int, match.groups())) >= (0, 143, 0) else 1) +PY +then + echo "SKIP: Codex plugin E2E requires codex-cli 0.143+; found: ${codex_version:-unknown}" + 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 +} + +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 'codex-sidecar*.pid')" + if [[ -n "$pid_file" && -f "$pid_file" ]]; then + sidecar_pid="$(cat "$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 + rm -rf "$work" +} +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_PLUGIN_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 +} + +# 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 'codex-sidecar*.pid')" +[[ -s "$old_sidecar_pid_file" ]] +old_sidecar_pid="$(cat "$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 'codex-sidecar*.pid')" +replacement_owner_file="$(find_sidecar_file 'codex-sidecar*.owner.json')" +[[ -s "$replacement_pid_file" && -s "$replacement_owner_file" ]] +replacement_pid="$(cat "$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 'codex-sidecar*.pid')" +[[ -s "$sidecar_pid_file" ]] +shared_sidecar_pid="$(cat "$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=("") +[[ "$(cat "$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 'codex-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 +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-support/codex_mock_provider.py b/scripts/test-support/codex_mock_provider.py new file mode 100644 index 000000000..3d530bd95 --- /dev/null +++ b/scripts/test-support/codex_mock_provider.py @@ -0,0 +1,235 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Local OpenAI Responses API fixture for the opt-in Codex plugin E2E test.""" + +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 + + +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}, + ] + + +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 + self.server.log_request_record( + { + "method": "GET", + "path": self.path, + "authorization": self.headers.get("authorization"), + } + ) + if not self.path.endswith("/models"): + self.send_error(404) + return + body = json.dumps( + { + "object": "list", + "data": [{"id": "gpt-5-codex", "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"{}") + events = response_events(request) if self.path.endswith("/responses") else None + self.server.log_request_record( + { + "method": "POST", + "path": self.path, + "authorization": self.headers.get("authorization"), + "model": request.get("model"), + "response_id": events[-1]["response"]["id"] if events else None, + } + ) + if events is None: + self.send_error(404) + return + self.server.wait_at_barrier_if_enabled() + 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() + for event in events: + self.wfile.write(f"data: {json.dumps(event)}\n\n".encode()) + self.wfile.write(b"data: [DONE]\n\n") + 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()