diff --git a/ATTRIBUTIONS-Rust.md b/ATTRIBUTIONS-Rust.md index 4bdfa809e..6f139889f 100644 --- a/ATTRIBUTIONS-Rust.md +++ b/ATTRIBUTIONS-Rust.md @@ -20911,6 +20911,27 @@ limitations under the License. ``` +## json5 - 0.4.1 +**Repository URL**: https://github.com/callum-oakley/json5-rs +**License Type(s)**: ISC +### License: https://spdx.org/licenses/ISC.html +``` +Copyright 2018 Callum Oakley + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +PERFORMANCE OF THIS SOFTWARE. + +``` + ## jsonschema - 0.46.8 **Repository URL**: https://github.com/Stranger6667/jsonschema **License Type(s)**: MIT @@ -26249,38 +26270,874 @@ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -``` +``` + +## outref - 0.5.2 +**Repository URL**: https://github.com/Nugine/outref +**License Type(s)**: MIT +### License: https://spdx.org/licenses/MIT.html +``` +MIT License + +Copyright (c) 2022 Nugine + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +``` + +## parking - 2.2.1 +**Repository URL**: https://github.com/smol-rs/parking +**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. + +``` + +## parking_lot - 0.12.5 +**Repository URL**: https://github.com/Amanieu/parking_lot +**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. + +``` + +## parking_lot_core - 0.9.12 +**Repository URL**: https://github.com/Amanieu/parking_lot +**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. + +``` + +## percent-encoding - 2.3.2 +**Repository URL**: https://github.com/servo/rust-url/ +**License Type(s)**: Apache-2.0 +### License: https://spdx.org/licenses/Apache-2.0.html +``` + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. -## outref - 0.5.2 -**Repository URL**: https://github.com/Nugine/outref -**License Type(s)**: MIT -### License: https://spdx.org/licenses/MIT.html -``` -MIT License +Copyright [yyyy] [name of copyright owner] -Copyright (c) 2022 Nugine +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: + http://www.apache.org/licenses/LICENSE-2.0 -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. ``` -## parking - 2.2.1 -**Repository URL**: https://github.com/smol-rs/parking +## pest - 2.8.7 +**Repository URL**: https://github.com/pest-parser/pest **License Type(s)**: Apache-2.0 ### License: https://spdx.org/licenses/Apache-2.0.html ``` @@ -26488,8 +27345,8 @@ limitations under the License. ``` -## parking_lot - 0.12.5 -**Repository URL**: https://github.com/Amanieu/parking_lot +## pest_derive - 2.8.7 +**Repository URL**: https://github.com/pest-parser/pest **License Type(s)**: Apache-2.0 ### License: https://spdx.org/licenses/Apache-2.0.html ``` @@ -26697,8 +27554,8 @@ limitations under the License. ``` -## parking_lot_core - 0.9.12 -**Repository URL**: https://github.com/Amanieu/parking_lot +## pest_generator - 2.8.7 +**Repository URL**: https://github.com/pest-parser/pest **License Type(s)**: Apache-2.0 ### License: https://spdx.org/licenses/Apache-2.0.html ``` @@ -26906,8 +27763,8 @@ limitations under the License. ``` -## percent-encoding - 2.3.2 -**Repository URL**: https://github.com/servo/rust-url/ +## pest_meta - 2.8.7 +**Repository URL**: https://github.com/pest-parser/pest **License Type(s)**: Apache-2.0 ### License: https://spdx.org/licenses/Apache-2.0.html ``` @@ -41783,6 +42640,215 @@ See the License for the specific language governing permissions and limitations under the License. ``` +## ucd-trie - 0.1.7 +**Repository URL**: https://github.com/BurntSushi/ucd-generate +**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. + +``` + ## unicase - 2.9.0 **Repository URL**: https://github.com/seanmonstar/unicase **License Type(s)**: Apache-2.0 diff --git a/Cargo.lock b/Cargo.lock index a3d44f449..a78b61dd3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1271,6 +1271,17 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "json5" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96b0db21af676c1ce64250b5f40f3ce2cf27e4e47cb91ed91eb6fe9350b430c1" +dependencies = [ + "pest", + "pest_derive", + "serde", +] + [[package]] name = "jsonschema" version = "0.46.8" @@ -1541,7 +1552,9 @@ dependencies = [ "futures-util", "http", "http-body-util", + "json5", "jsonschema", + "libc", "nemo-relay", "nemo-relay-adaptive", "nemo-relay-pii-redaction", @@ -1955,6 +1968,48 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "pest" +version = "2.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47627dd7305c6a2d6c8c6bcd24c5a4c17dbbf425f4f9c5313e724b38fc9782e9" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b4254325ecad416ab689e27ba51da03ba01a9632bc6e108f5fe7c3c4ad29d58" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c4c0e91ead7a8f7acecbca6f003fc2e8282b1dbe2dd9c9d2f16aba42995e0a7" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pest_meta" +version = "2.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9744bc48116fee06334924bb5f2bad41eed5e89bd26e29b0b799f9a3f82c210" +dependencies = [ + "pest", +] + [[package]] name = "petgraph" version = "0.8.3" @@ -3376,6 +3431,12 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + [[package]] name = "unicase" version = "2.9.0" diff --git a/README.md b/README.md index e6d466fa5..1dc22195e 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ shared runtime for scopes, policy, plugins, and lifecycle events. | Goal | Start With... | |---|---| -| Observe Codex, Claude Code, or Hermes locally via CLI | [Quick Start CLI](https://docs.nvidia.com/nemo/relay/nemo-relay-cli/about) | +| Observe Codex, Claude Code, Hermes, or OpenClaw locally via CLI | [Quick Start CLI](https://docs.nvidia.com/nemo/relay/nemo-relay-cli/about) | | Instrument app-owned LLM or tool calls | [Quick Start Application](https://docs.nvidia.com/nemo/relay/getting-started/quick-start) | | Use LangChain, LangGraph, Deep Agents, or OpenClaw | [Supported Integrations](https://docs.nvidia.com/nemo/relay/supported-integrations/about) | | Build a framework or provider integration | [Integrate into Frameworks](https://docs.nvidia.com/nemo/relay/integrate-into-frameworks/about) | @@ -43,9 +43,8 @@ build from. ### Local Agent Trajectory This walkthrough shows an end-to-end quick success setup. Install the -`nemo-relay-cli`, turn on local exporters, run either Codex or Claude Code -through Relay, and check that Relay wrote both raw events and normalized -trajectories. +`nemo-relay-cli`, turn on local exporters, run a supported coding agent through +Relay, and check that Relay wrote both raw events and normalized trajectories. #### 1. Install the CLI @@ -100,7 +99,7 @@ then configure these sections: > Use `nemo-relay plugins edit` _without_ `--project` only if needing to use these > exporter settings in a user-level Relay config instead of a specific project. -#### 3. Run Codex or Claude Code Through Relay +#### 3. Run a Supported Coding Agent Through Relay Use either host CLI that is installed on a machine. For example: @@ -112,6 +111,20 @@ nemo-relay codex -- exec "Summarize this repository." nemo-relay claude -- "Summarize this repository." ``` +OpenClaw can be launched through the same ephemeral gateway when its effective +provider is deterministically API-key-backed: + +```bash +nemo-relay openclaw -- agent --message "Summarize this repository." +``` + +Relay uses a temporary JSON5 include overlay to inject a lightweight hook bridge +and provider routing, then removes both when the child or launcher exits. The +bridge forwards into the same CLI-owned Relay runtime as the provider gateway; +it does not load a second Relay instance. Remote OpenClaw gateways, custom +providers, and ambiguous OAuth/token routes are left unchanged rather than being +claimed as intercepted. + Refer to the full [Quick Start CLI](https://docs.nvidia.com/nemo/relay/nemo-relay-cli/about) docs for more options. The transparent wrapper starts a local Relay gateway, injects host-specific hook @@ -288,6 +301,7 @@ coverage. | 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. | | Hermes Agent | Yes | Yes | Partial | Hook forwarding, pre-tool blocking, and gateway-routed or hook-backed LLM observability are supported. | +| OpenClaw | Yes | Partial | Partial | The CLI wrapper injects hook forwarding and routes local or foreground API-key-backed Anthropic/OpenAI requests through one Relay runtime. | ### Public API Integrations diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index b38d57413..32817d648 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -40,6 +40,8 @@ http = "1" http-body-util = "0.1" dialoguer = { version = "0.11", default-features = false, features = ["password"] } jsonschema = { version = "0.46.6", default-features = false } +json5 = "0.4" +libc = "0.2" percent-encoding = "2" reqwest = { version = "0.12", default-features = false, features = ["charset", "http2", "json", "rustls-tls-native-roots", "stream"] } regex = "1" diff --git a/crates/cli/README.md b/crates/cli/README.md index b5436a5d6..e8af02aff 100644 --- a/crates/cli/README.md +++ b/crates/cli/README.md @@ -26,7 +26,7 @@ with the installed `nemo-relay` command rather than link against the crate. ## Why Use It? -- **Observe existing coding agents**: Run Claude Code, Codex, or Hermes +- **Observe existing coding agents**: Run Claude Code, Codex, Hermes, or OpenClaw Agent through a local NeMo Relay gateway without changing the agent itself. - **Configure hooks interactively**: Use the setup wizard to write project or @@ -43,8 +43,8 @@ with the installed `nemo-relay` command rather than link against the crate. Cargo package. - **First-run setup**: Bare `nemo-relay` launches setup when no config exists, then runs doctor once config is present. -- **Agent shortcuts**: `nemo-relay claude`, `nemo-relay codex`, and - `nemo-relay hermes` start observed agent runs. +- **Agent shortcuts**: `nemo-relay claude`, `nemo-relay codex`, + `nemo-relay hermes`, and `nemo-relay openclaw` start observed agent runs. - **Config-driven launch**: `nemo-relay run` resolves config, environment, and CLI overrides for deterministic non-interactive use. - **Hook forwarding server**: A local gateway accepts agent hook events and @@ -99,8 +99,18 @@ Run a supported agent through the gateway: ```bash nemo-relay codex nemo-relay claude -- "summarize this repository" +nemo-relay openclaw -- agent --message "summarize this repository" ``` +OpenClaw launches in an embedded local or foreground-gateway mode so the +temporary provider overlay reaches the model-serving process. Relay routes +deterministically API-key-backed Anthropic Messages, OpenAI Chat Completions, +and OpenAI Responses providers while leaving custom, remote, or ambiguous +provider paths unchanged. The overlay also injects a temporary hook bridge that +forwards OpenClaw lifecycle events into the CLI gateway. The bridge has no Relay +runtime dependency, and any separately configured embedded Relay plugin is +disabled for the wrapped invocation so only the CLI-owned runtime is active. + Use `run --dry-run` to inspect resolved config without spawning the agent: ```bash diff --git a/crates/cli/src/adapters/mod.rs b/crates/cli/src/adapters/mod.rs index ce60db69d..51dd09740 100644 --- a/crates/cli/src/adapters/mod.rs +++ b/crates/cli/src/adapters/mod.rs @@ -4,6 +4,7 @@ pub(crate) mod claude_code; pub(crate) mod codex; pub(crate) mod hermes; +pub(crate) mod openclaw; use axum::http::HeaderMap; use serde_json::{Map, Value, json}; @@ -180,11 +181,13 @@ pub(crate) trait AgentPayloadExtractor { pub(super) struct ClaudeCodePayloadExtractor; pub(super) struct CodexPayloadExtractor; pub(super) struct HermesPayloadExtractor; +pub(super) struct OpenClawPayloadExtractor; pub(super) static CLAUDE_CODE_PAYLOAD_EXTRACTOR: ClaudeCodePayloadExtractor = ClaudeCodePayloadExtractor; pub(super) static CODEX_PAYLOAD_EXTRACTOR: CodexPayloadExtractor = CodexPayloadExtractor; pub(super) static HERMES_PAYLOAD_EXTRACTOR: HermesPayloadExtractor = HermesPayloadExtractor; +pub(super) static OPENCLAW_PAYLOAD_EXTRACTOR: OpenClawPayloadExtractor = OpenClawPayloadExtractor; /// Claude Code reports its native tool identifier as `tool_use_id`, so it uses /// a tool path set that prefers that key. Every other hook field matches the @@ -222,6 +225,14 @@ impl AgentPayloadExtractor for HermesPayloadExtractor { } } +/// OpenClaw's temporary CLI bridge supplies Relay-normalized field names and +/// must not inherit Claude Code's installed-mode session header. +impl AgentPayloadExtractor for OpenClawPayloadExtractor { + fn session_header_policy(&self) -> SessionHeaderPolicy { + SessionHeaderPolicy::RelayOnly + } +} + pub(crate) struct ToolPathSet { call_id: &'static [&'static [&'static str]], name: &'static [&'static [&'static str]], @@ -886,7 +897,7 @@ fn classify_primary( } else { match normalized.as_str() { "afteragentresponse" | "agentresponse" | "assistantresponse" | "afteragentthought" - | "prellmcall" | "postllmcall" | "stop" => { + | "llminput" | "prellmcall" | "postllmcall" | "stop" => { NormalizedEvent::LlmHint(common_llm_hint_event_with_fallback( payload, headers, diff --git a/crates/cli/src/adapters/openclaw.rs b/crates/cli/src/adapters/openclaw.rs new file mode 100644 index 000000000..de2aea2da --- /dev/null +++ b/crates/cli/src/adapters/openclaw.rs @@ -0,0 +1,33 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use axum::http::HeaderMap; +use serde_json::{Value, json}; + +use crate::adapters::{AdapterOutcome, ClassificationRules, OPENCLAW_PAYLOAD_EXTRACTOR, classify}; +use crate::model::AgentKind; + +/// Normalizes events emitted by the temporary OpenClaw CLI bridge. +/// +/// The bridge is observational: model request mutation remains on the gateway's +/// managed LLM path, while these events provide session and tool correlation. +pub(crate) fn adapt(payload: Value, headers: &HeaderMap) -> AdapterOutcome { + let events = classify( + &payload, + headers, + &OPENCLAW_PAYLOAD_EXTRACTOR, + &ClassificationRules { + kind: AgentKind::OpenClaw, + agent_start: &["session_start"], + agent_end: &["session_end"], + subagent_start: &["subagent_start"], + subagent_end: &["subagent_end"], + tool_start: &["tool_start"], + tool_end: &["tool_end"], + }, + ); + AdapterOutcome { + events, + response: json!({}), + } +} diff --git a/crates/cli/src/config.rs b/crates/cli/src/config.rs index d4491d6f6..369c29e5c 100644 --- a/crates/cli/src/config.rs +++ b/crates/cli/src/config.rs @@ -69,6 +69,20 @@ pub(crate) enum Command { nemo-relay hermes -- chat --provider custom" )] Hermes(EasyPathCommand), + /// Run OpenClaw with observability (setup on first use) + #[command( + name = "openclaw", + long_about = "Run OpenClaw under an ephemeral NeMo Relay gateway. Relay creates a \ + temporary JSON5 overlay that injects hook forwarding and routes \ + API-key-backed canonical Anthropic and OpenAI providers through one \ + CLI-owned Relay runtime without modifying OpenClaw's source configuration, \ + state, credentials, or plugin installation.", + after_help = "Examples:\n \ + nemo-relay openclaw\n \ + nemo-relay openclaw -- agent --agent main --message \"summarize this project\"\n \ + nemo-relay openclaw -- gateway run" + )] + OpenClaw(EasyPathCommand), /// Run the interactive setup (writes `.nemo-relay/config.toml`) Config(ConfigCommand), /// Create or edit plugin configuration (writes `plugins.toml`) @@ -526,6 +540,8 @@ pub(crate) enum CodingAgent { ClaudeCode, Codex, Hermes, + #[value(name = "openclaw")] + OpenClaw, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, ValueEnum)] @@ -613,6 +629,7 @@ pub(crate) struct AgentConfigs { pub(crate) claude: AgentCommandConfig, pub(crate) codex: AgentCommandConfig, pub(crate) hermes: AgentCommandConfig, + pub(crate) openclaw: AgentCommandConfig, } #[derive(Debug, Clone, Default)] @@ -646,12 +663,13 @@ struct FileUpstreamConfig { #[derive(Debug, Clone, Default, Deserialize)] struct FileAgentsConfig { - // Keys match the agent's CLI invocation name (`claude`, `codex`, `hermes`) — the + // Keys match the agent's CLI invocation name (`claude`, `codex`, `hermes`, `openclaw`) — the // word the user types at the shell — not the product name ("Claude Code") or the internal // `CodingAgent` enum kebab spelling. Same convention as the bare-agent shortcut in Phase 2. claude: Option, codex: Option, hermes: Option, + openclaw: Option, } #[derive(Debug, Clone, Default, Deserialize)] @@ -1243,6 +1261,9 @@ fn apply_file_agents_config(agents: &mut AgentConfigs, file_agents: Option "/hooks/claude-code", Self::Codex => "/hooks/codex", Self::Hermes => "/hooks/hermes", + Self::OpenClaw => "/hooks/openclaw", } } @@ -1362,6 +1384,7 @@ impl CodingAgent { Self::ClaudeCode => "claude", Self::Codex => "codex", Self::Hermes => "hermes", + Self::OpenClaw => "openclaw", } } @@ -1376,6 +1399,7 @@ impl CodingAgent { "claude" | "claude-code" => Some(Self::ClaudeCode), "codex" => Some(Self::Codex), "hermes" | "hermes-agent" => Some(Self::Hermes), + "openclaw" | "openclaw.exe" => Some(Self::OpenClaw), _ => None, } } diff --git a/crates/cli/src/doctor.rs b/crates/cli/src/doctor.rs index b272d5388..95662e99b 100644 --- a/crates/cli/src/doctor.rs +++ b/crates/cli/src/doctor.rs @@ -397,6 +397,7 @@ async fn collect_agents( (CodingAgent::ClaudeCode, "claude", "claude"), (CodingAgent::Codex, "codex", "codex"), (CodingAgent::Hermes, "hermes", "hermes"), + (CodingAgent::OpenClaw, "openclaw", "openclaw"), ]; let mut out = Vec::with_capacity(supported.len()); for (agent, display_name, default_exec) in supported { @@ -473,6 +474,7 @@ fn configured_agent_command(agent: CodingAgent, agents: &AgentConfigs) -> Option CodingAgent::ClaudeCode => agents.claude.command.as_ref(), CodingAgent::Codex => agents.codex.command.as_ref(), CodingAgent::Hermes => agents.hermes.command.as_ref(), + CodingAgent::OpenClaw => agents.openclaw.command.as_ref(), } } @@ -486,6 +488,7 @@ fn configured_agent_names(agents: &AgentConfigs) -> Vec { (CodingAgent::ClaudeCode, "claude"), (CodingAgent::Codex, "codex"), (CodingAgent::Hermes, "hermes"), + (CodingAgent::OpenClaw, "openclaw"), ] .into_iter() .filter_map(|(agent, name)| agent_configured(agent, agents).then_some(name.to_string())) @@ -533,6 +536,10 @@ fn hook_status( ), None => (Status::Info, "hooks: not configured".into()), }, + CodingAgent::OpenClaw => ( + Status::Pass, + "hooks: temporary CLI bridge injected during run".into(), + ), } } diff --git a/crates/cli/src/installer.rs b/crates/cli/src/installer.rs index 364f229e9..48960dd52 100644 --- a/crates/cli/src/installer.rs +++ b/crates/cli/src/installer.rs @@ -201,6 +201,7 @@ pub(crate) fn generated_hooks(agent: CodingAgent, command: &str) -> Value { CodingAgent::ClaudeCode => claude_hooks(command), CodingAgent::Codex => codex_hooks(command), CodingAgent::Hermes => hermes_hooks(command), + CodingAgent::OpenClaw => json!({"hooks": {}}), } } diff --git a/crates/cli/src/launcher.rs b/crates/cli/src/launcher.rs index 0147c83a0..9b8ff3968 100644 --- a/crates/cli/src/launcher.rs +++ b/crates/cli/src/launcher.rs @@ -1,6 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +use std::future::Future; use std::path::{Path, PathBuf}; use std::process::ExitCode; use std::time::{Duration, SystemTime, UNIX_EPOCH}; @@ -101,6 +102,12 @@ impl TransparentRun { async fn new(command: RunCommand, inherited: Option<&ServerArgs>) -> Result { let dry_run = command.dry_run; let print = command.print; + let explicit_openclaw_upstreams = crate::openclaw::ExplicitUpstreams { + openai: command.openai_base_url.is_some() + || inherited.is_some_and(|args| args.openai_base_url.is_some()), + anthropic: command.anthropic_base_url.is_some() + || inherited.is_some_and(|args| args.anthropic_base_url.is_some()), + }; let explicit_config = command .config .as_ref() @@ -111,13 +118,30 @@ impl TransparentRun { } else { crate::plugins::lifecycle::active_dynamic_plugin_components(explicit_config, &resolved)? }; - let (agent, argv) = resolve_agent_and_argv(&command, &resolved.agents)?; + let (agent, mut argv) = resolve_agent_and_argv(&command, &resolved.agents)?; let listener = TcpListener::bind("127.0.0.1:0").await?; let address = listener.local_addr()?; let gateway_url = format!("http://{address}"); resolved.gateway.bind = address; - let prepared = PreparedRun::new(agent, argv, &gateway_url, &resolved, dry_run)?; + let openclaw = if agent == CodingAgent::OpenClaw { + Some(crate::openclaw::prepare( + &mut argv, + &gateway_url, + &mut resolved.gateway, + explicit_openclaw_upstreams, + dry_run, + )?) + } else { + None + }; + let mut prepared = PreparedRun::new(agent, argv, &gateway_url, &resolved, dry_run)?; + if let Some(openclaw) = openclaw { + prepared.env.extend(openclaw.env); + prepared.temp_dirs.extend(openclaw.temp_dirs); + prepared.temp_files.extend(openclaw.temp_files); + prepared.notes.extend(openclaw.notes); + } Ok(Self { agent, prepared, @@ -176,15 +200,49 @@ async fn execute_live_run_with_dynamic( gateway_url: &str, prepared: PreparedRun, ) -> Result { + execute_live_run_with_dynamic_and_shutdown( + listener, + gateway_config, + dynamic_plugins, + gateway_url, + prepared, + launcher_shutdown_signal(), + ) + .await +} + +async fn execute_live_run_with_dynamic_and_shutdown( + listener: TcpListener, + gateway_config: GatewayConfig, + dynamic_plugins: Vec, + gateway_url: &str, + prepared: PreparedRun, + shutdown: F, +) -> Result +where + F: Future>, +{ let running_server = RunningGateway::start(listener, gateway_config, dynamic_plugins); - if let Err(error) = wait_for_health(gateway_url).await { + tokio::pin!(shutdown); + let health = tokio::select! { + result = wait_for_health(gateway_url) => result, + signal = &mut shutdown => { + let restore = prepared.restore(); + let server_result = running_server.stop().await; + signal?; + restore?; + server_result?; + return Ok(ExitCode::FAILURE); + } + }; + if let Err(error) = health { let restore = prepared.restore(); let server_result = running_server.stop().await; restore?; server_result?; return Err(error); } - let status = prepared.spawn_and_wait().await; + let status = prepared.spawn_and_wait_with_shutdown(&mut shutdown).await; let restore = prepared.restore(); let server_result = running_server.stop().await; restore?; @@ -193,6 +251,28 @@ async fn execute_live_run_with_dynamic( Ok(exit_code(status?)) } +#[cfg(test)] +async fn execute_live_run_with_shutdown( + listener: TcpListener, + gateway_config: GatewayConfig, + gateway_url: &str, + prepared: PreparedRun, + shutdown: F, +) -> Result +where + F: Future>, +{ + execute_live_run_with_dynamic_and_shutdown( + listener, + gateway_config, + Vec::new(), + gateway_url, + prepared, + shutdown, + ) + .await +} + // Resolves the launched agent and argv from either an explicit command or a configured per-agent // command. Agent inference only happens from argv[0] when `--agent` was omitted, so explicit agent // selection can wrap commands whose executable name is not recognizable. @@ -232,6 +312,7 @@ const fn default_command_for(agent: CodingAgent) -> &'static str { CodingAgent::ClaudeCode => "claude", CodingAgent::Codex => "codex", CodingAgent::Hermes => "hermes", + CodingAgent::OpenClaw => "openclaw", } } @@ -243,7 +324,7 @@ fn resolved_agent(command: &RunCommand, argv: &[String]) -> Result Option agents.claude.command.as_ref(), CodingAgent::Codex => agents.codex.command.as_ref(), CodingAgent::Hermes => agents.hermes.command.as_ref(), + CodingAgent::OpenClaw => agents.openclaw.command.as_ref(), }?; let argv: Vec<_> = command.split_whitespace().map(ToOwned::to_owned).collect(); (!argv.is_empty()).then_some(argv) @@ -266,6 +348,7 @@ struct PreparedRun { argv: Vec, env: Vec<(String, String)>, temp_dirs: Vec, + temp_files: Vec, hermes_restore: Option, notes: Vec, } @@ -281,6 +364,17 @@ struct RunningGateway { task: JoinHandle>, } +impl Drop for PreparedRun { + fn drop(&mut self) { + for file in &self.temp_files { + let _ = std::fs::remove_file(file); + } + for dir in &self.temp_dirs { + let _ = std::fs::remove_dir_all(dir); + } + } +} + impl RunningGateway { // Starts the gateway listener on a background task and keeps the shutdown sender paired with the // task handle so health failures and normal exits use identical cleanup semantics. @@ -312,6 +406,37 @@ impl RunningGateway { } } +async fn launcher_shutdown_signal() -> Result<(), CliError> { + #[cfg(unix)] + { + let mut terminate = + tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) + .map_err(CliError::from)?; + tokio::select! { + result = tokio::signal::ctrl_c() => result.map_err(CliError::from), + _ = terminate.recv() => Ok(()), + } + } + #[cfg(not(unix))] + { + tokio::signal::ctrl_c().await.map_err(CliError::from) + } +} + +fn request_child_shutdown(child: &mut tokio::process::Child) { + #[cfg(unix)] + if let Some(pid) = child.id() { + // The child may already have received the terminal's SIGINT/SIGTERM through the + // foreground process group. A second graceful SIGTERM is harmless and also covers + // service managers that signal only the Relay parent. + unsafe { + libc::kill(pid as libc::pid_t, libc::SIGTERM); + } + } + #[cfg(not(unix))] + let _ = child; +} + impl PreparedRun { // Builds the launch plan and applies only the preparation needed by the selected agent. // Dry-run preparation records equivalent notes and argv/env changes without writing temporary @@ -327,6 +452,7 @@ impl PreparedRun { argv, env: vec![("NEMO_RELAY_GATEWAY_URL".into(), gateway_url.into())], temp_dirs: Vec::new(), + temp_files: Vec::new(), hermes_restore: None, notes: Vec::new(), }; @@ -349,6 +475,9 @@ impl PreparedRun { run.prepare_hermes(resolved.agents.hermes.hooks_path.as_deref())?; } } + // OpenClaw preparation happens before this generic constructor because it also + // selects provider-specific upstreams on the gateway configuration. + CodingAgent::OpenClaw => {} } Ok(run) } @@ -489,14 +618,34 @@ impl PreparedRun { // Spawns the prepared child process with injected environment and waits for its exit status. // Stdio is inherited by default so agent interaction remains unchanged in transparent mode. - async fn spawn_and_wait(&self) -> Result { + async fn spawn_and_wait_with_shutdown( + &self, + shutdown: F, + ) -> Result + where + F: Future>, + { let mut command = Command::new(&self.argv[0]); command.args(&self.argv[1..]); for (name, value) in &self.env { command.env(name, value); } let mut child = command.spawn()?; - child.wait().await.map_err(CliError::from) + tokio::pin!(shutdown); + tokio::select! { + status = child.wait() => status.map_err(CliError::from), + signal = &mut shutdown => { + signal?; + request_child_shutdown(&mut child); + match tokio::time::timeout(Duration::from_secs(5), child.wait()).await { + Ok(status) => status.map_err(CliError::from), + Err(_) => { + let _ = child.start_kill(); + child.wait().await.map_err(CliError::from) + } + } + } + } } // Removes temporary directories and restores patched hook files after the child exits. Restore @@ -505,6 +654,16 @@ impl PreparedRun { for dir in &self.temp_dirs { let _ = std::fs::remove_dir_all(dir); } + for file in &self.temp_files { + if let Err(error) = std::fs::remove_file(file) + && error.kind() != std::io::ErrorKind::NotFound + { + return Err(CliError::Launch(format!( + "failed to remove temporary launcher file {}: {error}", + file.display() + ))); + } + } if let Some(hermes) = &self.hermes_restore { restore_hook_file( @@ -520,14 +679,21 @@ impl PreparedRun { // Prints a compact pre-launch status banner so users see at a glance which plugin // configuration is active, including plugin names and enabled/disabled state, before the // agent's own UI takes over the terminal. Always emitted on stderr so it never contaminates - // piped/redirected agent output, and suppressed entirely when stdout is not a TTY — scripts - // capturing the agent stream get a clean pipe, interactive users still get the bordered frame. + // piped/redirected agent output. The decorative frame is suppressed when stdout is not a TTY, + // but OpenClaw routing decisions still go to stderr so passthrough is never silent. // Distinct from `print()`, which is the verbose `--print` / `--dry-run` dump intended for // inspection. fn print_live_status(&self, agent: CodingAgent, gateway_url: &str, resolved: &ResolvedConfig) { // Suppress entirely on non-TTY stdout: when the user redirects the agent's stream to a - // file or pipes it into another tool, no banner should appear ahead of that output. + // file or pipes it into another tool, no banner should appear ahead of that output. Keep + // OpenClaw routing notes visible on stderr because they are part of the interception + // contract rather than decoration. if !std::io::IsTerminal::is_terminal(&std::io::stdout()) { + if agent == CodingAgent::OpenClaw { + for note in &self.notes { + eprintln!("nemo-relay openclaw: {note}"); + } + } return; } diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index d5947b0b0..9e5acea00 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -16,6 +16,7 @@ mod json_path; mod launcher; mod model; mod model_pricing; +mod openclaw; mod plugin_install; mod plugin_shim; mod plugins; @@ -80,6 +81,9 @@ async fn run_command(command: Command, server: &ServerArgs) -> Result { launcher::easy_path(CodingAgent::Hermes, command, Some(server)).await } + Command::OpenClaw(command) => { + launcher::easy_path(CodingAgent::OpenClaw, command, Some(server)).await + } Command::Config(command) => run_config(command).await, Command::Plugins(command) => run_plugins(command, server), Command::ModelPricing(command) => run_pricing(command), diff --git a/crates/cli/src/model.rs b/crates/cli/src/model.rs index 4d7e8a792..48075fd64 100644 --- a/crates/cli/src/model.rs +++ b/crates/cli/src/model.rs @@ -8,6 +8,7 @@ pub(crate) enum AgentKind { Codex, ClaudeCode, Hermes, + OpenClaw, Gateway, } @@ -19,6 +20,7 @@ impl AgentKind { Self::Codex => "codex", Self::ClaudeCode => "claude-code", Self::Hermes => "hermes", + Self::OpenClaw => "openclaw", Self::Gateway => "gateway", } } diff --git a/crates/cli/src/openclaw.rs b/crates/cli/src/openclaw.rs new file mode 100644 index 000000000..c223c08ec --- /dev/null +++ b/crates/cli/src/openclaw.rs @@ -0,0 +1,1261 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! OpenClaw transparent-launch preparation. + +use std::ffi::OsString; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output, Stdio}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use serde_json::{Map, Value, json}; + +use crate::config::GatewayConfig; +use crate::error::CliError; + +const OPENCLAW_CONFIG_PATH: &str = "OPENCLAW_CONFIG_PATH"; +const RELAY_OPENCLAW_PLUGIN_ID: &str = "nemo-relay"; +const RELAY_OPENCLAW_BRIDGE_ID: &str = "nemo-relay-cli-bridge"; +const DEFAULT_OPENAI_BASE_URL: &str = "https://api.openai.com/v1"; +const DEFAULT_ANTHROPIC_BASE_URL: &str = "https://api.anthropic.com"; + +const BRIDGE_PACKAGE_JSON: &str = r#"{ + "name": "nemo-relay-cli-openclaw-bridge", + "version": "0.0.0", + "private": true, + "type": "module", + "openclaw": { + "extensions": ["./index.js"] + } +} +"#; + +const BRIDGE_MANIFEST_JSON: &str = r#"{ + "id": "nemo-relay-cli-bridge", + "name": "NeMo Relay CLI Bridge", + "description": "Temporary hook bridge owned by the NeMo Relay CLI wrapper.", + "activation": {"onStartup": true}, + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} +"#; + +const BRIDGE_INDEX_JS: &str = r#"const HOOK_EVENT_NAMES = { + session_start: "session_start", + session_end: "session_end", + llm_input: "preLlmCall", + before_tool_call: "tool_start", + after_tool_call: "tool_end", + subagent_spawned: "subagent_start", + subagent_ended: "subagent_end", +}; + +let warned = false; + +function firstString(...values) { + return values.find((value) => typeof value === "string" && value.length > 0); +} + +function payloadFor(hookName, event = {}, ctx = {}) { + const sessionId = firstString( + event.sessionId, + ctx.sessionId, + event.sessionKey, + ctx.sessionKey, + event.requesterSessionKey, + ctx.requesterSessionKey, + event.runId, + ctx.runId, + ); + const toolCallId = firstString(event.toolCallId, ctx.toolCallId); + const toolName = firstString(event.toolName, ctx.toolName); + return { + ...event, + hook_event_name: HOOK_EVENT_NAMES[hookName] ?? hookName, + session_id: sessionId, + conversation_id: firstString(event.sessionKey, ctx.sessionKey, sessionId), + request_id: firstString(event.callId, event.requestId, event.runId, ctx.runId), + generation_id: firstString(event.callId, event.runId, ctx.runId), + agent_id: firstString(event.agentId, ctx.agentId), + subagent_id: firstString(event.childSessionKey, event.targetSessionKey), + tool_call_id: toolCallId, + tool_name: toolName, + tool_input: event.params, + tool_output: event.result, + status: event.error ? "error" : undefined, + }; +} + +async function forward(api, hookName, event, ctx) { + const baseUrl = process.env.NEMO_RELAY_GATEWAY_URL; + if (!baseUrl) return; + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 2000); + timer.unref?.(); + try { + const response = await fetch(`${baseUrl.replace(/\/$/, "")}/hooks/openclaw`, { + method: "POST", + headers: {"content-type": "application/json"}, + body: JSON.stringify(payloadFor(hookName, event, ctx)), + signal: controller.signal, + }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + } catch (error) { + if (!warned) { + warned = true; + api.logger.warn?.(`NeMo Relay CLI hook forwarding degraded: ${String(error)}`); + } + } finally { + clearTimeout(timer); + } +} + +export default { + id: "nemo-relay-cli-bridge", + name: "NeMo Relay CLI Bridge", + register(api) { + for (const hookName of Object.keys(HOOK_EVENT_NAMES)) { + api.on(hookName, (event, ctx) => forward(api, hookName, event, ctx)); + } + }, +}; +"#; + +/// Records which Relay upstream flags were explicitly supplied by the caller. +#[derive(Debug, Clone, Copy, Default)] +pub(crate) struct ExplicitUpstreams { + pub(crate) openai: bool, + pub(crate) anthropic: bool, +} + +/// Files, environment, and status notes added to an OpenClaw child process. +#[derive(Debug, Default)] +pub(crate) struct Preparation { + pub(crate) env: Vec<(String, String)>, + pub(crate) temp_dirs: Vec, + pub(crate) temp_files: Vec, + pub(crate) notes: Vec, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +enum AuthProfileEvidence { + /// The read-only OpenClaw probe found no saved profiles for this provider. + #[default] + None, + /// Every profile OpenClaw may select is an API key. + ApiKeyOnly, + /// Some agents have API-key profiles and the others have no saved profile. + ApiKeyOrNone, + /// At least one profile is OAuth/token-based, so selection or rotation is not deterministic. + NonApiKeyOrMixed, + /// The profile inventory could not be read safely. + Unknown, +} + +#[derive(Debug, Clone, Copy, Default)] +struct AuthProfileEvidenceSet { + openai: AuthProfileEvidence, + anthropic: AuthProfileEvidence, +} + +#[derive(Debug, Clone, Copy, Default)] +struct TrustedApiKeySet { + openai: bool, + anthropic: bool, +} + +impl TrustedApiKeySet { + const fn get(self, family: ProviderFamily) -> bool { + match family { + ProviderFamily::OpenAi => self.openai, + ProviderFamily::Anthropic => self.anthropic, + } + } +} + +impl AuthProfileEvidenceSet { + const fn get(self, family: ProviderFamily) -> AuthProfileEvidence { + match family { + ProviderFamily::OpenAi => self.openai, + ProviderFamily::Anthropic => self.anthropic, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ProviderFamily { + OpenAi, + Anthropic, +} + +impl ProviderFamily { + const fn id(self) -> &'static str { + match self { + Self::OpenAi => "openai", + Self::Anthropic => "anthropic", + } + } + + const fn default_api(self) -> &'static str { + match self { + Self::OpenAi => "openai-responses", + Self::Anthropic => "anthropic-messages", + } + } + + const fn default_base_url(self) -> &'static str { + match self { + Self::OpenAi => DEFAULT_OPENAI_BASE_URL, + Self::Anthropic => DEFAULT_ANTHROPIC_BASE_URL, + } + } + + fn is_api_key_name(self, name: &str) -> bool { + let exact = match self { + Self::OpenAi => [ + "OPENAI_API_KEY", + "OPENAI_API_KEYS", + "OPENCLAW_LIVE_OPENAI_KEY", + ], + Self::Anthropic => [ + "ANTHROPIC_API_KEY", + "ANTHROPIC_API_KEYS", + "OPENCLAW_LIVE_ANTHROPIC_KEY", + ], + }; + exact.contains(&name) + || match self { + Self::OpenAi => name.starts_with("OPENAI_API_KEY_"), + Self::Anthropic => name.starts_with("ANTHROPIC_API_KEY_"), + } + } + + fn has_api_key_environment(self) -> bool { + std::env::vars_os().any(|(name, value)| { + self.is_api_key_name(&name.to_string_lossy()) + && !value.to_string_lossy().trim().is_empty() + }) + } +} + +#[derive(Debug)] +struct RoutingPlan { + provider_overrides: Map, + openai_upstream: Option, + anthropic_upstream: Option, + notes: Vec, +} + +/// Prepare an OpenClaw process that owns the model request path. +/// +/// OpenClaw's `agent` and `tui` commands normally attach to a separately running +/// gateway. Those modes would leave the temporary config in the client process, +/// not the model-serving process, so this function forces their embedded local +/// modes. A foreground `gateway run` is also supported. Remote and detached +/// gateway modes fail before Relay claims interception. +pub(crate) fn prepare( + argv: &mut Vec, + gateway_url: &str, + gateway: &mut GatewayConfig, + explicit_upstreams: ExplicitUpstreams, + dry_run: bool, +) -> Result { + normalize_foreground_argv(argv)?; + add_invocation_metadata(gateway); + + let source_path = resolve_config_path(argv)?; + let (source_config, source_exists) = read_source_config(&source_path)?; + let disable_embedded_relay = source_config.as_ref().is_some_and(|config| { + config + .pointer("/plugins/entries") + .and_then(Value::as_object) + .is_some_and(|entries| entries.contains_key(RELAY_OPENCLAW_PLUGIN_ID)) + }) || probe_relay_plugin_installed(argv, &source_path); + let auth_profiles = AuthProfileEvidenceSet { + openai: probe_auth_profiles( + argv, + &source_path, + source_config.as_ref(), + ProviderFamily::OpenAi, + ), + anthropic: probe_auth_profiles( + argv, + &source_path, + source_config.as_ref(), + ProviderFamily::Anthropic, + ), + }; + let trusted_api_keys = trusted_api_key_sources(argv, source_config.as_ref()); + let mut routing = build_routing_plan_with_evidence( + source_config.as_ref(), + gateway_url, + auth_profiles, + trusted_api_keys, + ); + if let Some(upstream) = routing.openai_upstream.take() { + if explicit_upstreams.openai { + routing.notes.push( + "explicit Relay OpenAI upstream overrides the OpenClaw provider endpoint".into(), + ); + } else { + gateway.openai_base_url = upstream; + } + } + if let Some(upstream) = routing.anthropic_upstream.take() { + if explicit_upstreams.anthropic { + routing.notes.push( + "explicit Relay Anthropic upstream overrides the OpenClaw provider endpoint".into(), + ); + } else { + gateway.anthropic_base_url = upstream; + } + } + + if dry_run { + let mut notes = routing.notes; + notes.push(format!( + "would generate a temporary OpenClaw JSON5 overlay and CLI hook bridge for {}", + source_path.display() + )); + return Ok(Preparation { + env: vec![( + OPENCLAW_CONFIG_PATH.into(), + "".into(), + )], + temp_dirs: Vec::new(), + temp_files: Vec::new(), + notes, + }); + } + + let bridge_dir = write_bridge_plugin()?; + let overlay = overlay_document( + source_exists.then_some(source_path.as_path()), + source_config.as_ref(), + routing.provider_overrides, + &bridge_dir, + disable_embedded_relay, + ); + let overlay_path = match temporary_overlay_path(&source_path) { + Ok(path) => path, + Err(error) => { + let _ = std::fs::remove_dir_all(&bridge_dir); + return Err(error); + } + }; + let write_result = serde_json::to_vec_pretty(&overlay) + .map_err(|error| CliError::Launch(format!("could not serialize OpenClaw overlay: {error}"))) + .and_then(|contents| std::fs::write(&overlay_path, contents).map_err(CliError::from)); + if let Err(error) = write_result { + let _ = std::fs::remove_file(&overlay_path); + let _ = std::fs::remove_dir_all(&bridge_dir); + return Err(error); + } + + let env = vec![( + OPENCLAW_CONFIG_PATH.into(), + overlay_path.display().to_string(), + )]; + Ok(Preparation { + env, + temp_dirs: vec![bridge_dir], + temp_files: vec![overlay_path], + notes: routing + .notes + .into_iter() + .chain(["OpenClaw hooks forwarded to the CLI-owned Relay runtime".into()]) + .collect(), + }) +} + +fn normalize_foreground_argv(argv: &mut Vec) -> Result<(), CliError> { + let executable = openclaw_executable_index(argv).ok_or_else(|| { + CliError::Launch( + "could not locate the OpenClaw executable in the configured launch prefix; use `openclaw`, `npx openclaw`, or a supported package-manager exec form" + .into(), + ) + })?; + if has_container_option(&argv[executable + 1..]) { + return Err(CliError::Launch( + "OpenClaw --container execution cannot use Relay's host-side temporary provider overlay; run OpenClaw directly or launch a foreground host process" + .into(), + )); + } + let command = first_openclaw_positional(argv, executable) + .map(|(index, value)| (index, value.to_string())); + + let Some((command_index, command)) = command else { + if argv.len() == executable + 1 || contains_only_root_options(&argv[executable + 1..]) { + argv.extend(["tui".into(), "--local".into()]); + } + return Ok(()); + }; + + match command.as_str() { + "agent" => insert_local_flag(argv, command_index), + "tui" | "chat" | "terminal" => { + if has_remote_tui_options(&argv[command_index + 1..]) { + return Err(CliError::Launch( + "OpenClaw remote TUI options (--url, --token, or --password) bypass the \ + process carrying Relay's temporary provider overlay; use local mode or run \ + the target OpenClaw gateway in the foreground under Relay" + .into(), + )); + } + insert_local_flag(argv, command_index); + } + "gateway" => { + let action = argv[command_index + 1..] + .iter() + .find(|value| !value.starts_with('-')) + .map(String::as_str); + if action != Some("run") { + return Err(CliError::Launch( + "OpenClaw gateway interception requires the foreground `gateway run` \ + command; detached, service-managed, or remote gateways cannot retain \ + Relay's temporary configuration overlay" + .into(), + )); + } + } + other => { + return Err(CliError::Launch(format!( + "OpenClaw command `{other}` is not a supported Relay launch target; run configuration, plugin, model-management, and onboarding commands directly so their writes are not redirected into Relay's temporary overlay" + ))); + } + } + Ok(()) +} + +fn contains_only_root_options(args: &[String]) -> bool { + let mut index = 0; + while index < args.len() { + match args[index].as_str() { + "--dev" => index += 1, + "--profile" | "--log-level" if args.get(index + 1).is_some() => index += 2, + value if value.starts_with("--profile=") || value.starts_with("--log-level=") => { + index += 1; + } + _ => return false, + } + } + true +} + +fn has_container_option(args: &[String]) -> bool { + args.iter() + .any(|value| value == "--container" || value.starts_with("--container=")) +} + +fn insert_local_flag(argv: &mut Vec, command_index: usize) { + if !argv[command_index + 1..] + .iter() + .any(|value| value == "--local") + { + argv.insert(command_index + 1, "--local".into()); + } +} + +fn has_remote_tui_options(args: &[String]) -> bool { + args.iter().any(|value| { + matches!(value.as_str(), "--url" | "--token" | "--password") + || value.starts_with("--url=") + || value.starts_with("--token=") + || value.starts_with("--password=") + }) +} + +fn is_openclaw_executable(value: &str) -> bool { + let name = Path::new(value) + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or(value); + matches!(name, "openclaw" | "openclaw.exe") +} + +#[cfg(test)] +fn build_routing_plan(config: Option<&Value>, gateway_url: &str) -> RoutingPlan { + build_routing_plan_with_auth(config, gateway_url, AuthProfileEvidenceSet::default()) +} + +#[cfg(test)] +fn build_routing_plan_with_auth( + config: Option<&Value>, + gateway_url: &str, + auth_profiles: AuthProfileEvidenceSet, +) -> RoutingPlan { + build_routing_plan_with_evidence( + config, + gateway_url, + auth_profiles, + TrustedApiKeySet { + openai: ProviderFamily::OpenAi.has_api_key_environment() + || config + .is_some_and(|config| config_env_has_api_key(config, ProviderFamily::OpenAi)), + anthropic: ProviderFamily::Anthropic.has_api_key_environment() + || config.is_some_and(|config| { + config_env_has_api_key(config, ProviderFamily::Anthropic) + }), + }, + ) +} + +fn build_routing_plan_with_evidence( + config: Option<&Value>, + gateway_url: &str, + auth_profiles: AuthProfileEvidenceSet, + trusted_api_keys: TrustedApiKeySet, +) -> RoutingPlan { + let mut plan = RoutingPlan { + provider_overrides: Map::new(), + openai_upstream: None, + anthropic_upstream: None, + notes: Vec::new(), + }; + if config.is_some_and(contains_provider_include) { + plan.notes.push( + "OpenClaw provider routing left unchanged because its provider configuration uses \ + `$include`; Relay will not guess at an unresolved upstream" + .into(), + ); + return plan; + } + + let providers = config.and_then(provider_map); + if let Some(providers) = providers { + let custom = providers + .keys() + .filter(|id| id.as_str() != "openai" && id.as_str() != "anthropic") + .cloned() + .collect::>(); + if !custom.is_empty() { + plan.notes.push(format!( + "custom OpenClaw providers left unchanged and not intercepted: {}", + custom.join(", ") + )); + } + } + + for family in [ProviderFamily::OpenAi, ProviderFamily::Anthropic] { + if config.is_some_and(|config| has_non_http_agent_model_runtime(config, family)) { + plan.notes.push(format!( + "OpenClaw provider `{}` left unchanged and not intercepted: agent model policy selects a non-HTTP runtime", + family.id() + )); + continue; + } + let provider = providers.and_then(|providers| providers.get(family.id())); + if let Some(reason) = unsupported_provider_reason(family, provider) { + plan.notes.push(format!( + "OpenClaw provider `{}` left unchanged and not intercepted: {reason}", + family.id() + )); + continue; + } + if let Err(reason) = provider_uses_api_key( + provider, + auth_profiles.get(family), + trusted_api_keys.get(family), + ) { + plan.notes.push(format!( + "OpenClaw provider `{}` left unchanged: {reason}", + family.id(), + )); + continue; + } + + let api = provider + .and_then(Value::as_object) + .and_then(|provider| provider.get("api")) + .and_then(Value::as_str) + .unwrap_or_else(|| family.default_api()); + let upstream = provider + .and_then(Value::as_object) + .and_then(|provider| provider.get("baseUrl")) + .and_then(Value::as_str) + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| family.default_base_url()) + .to_string(); + let relay_base_url = match family { + ProviderFamily::OpenAi => format!("{}/v1", gateway_url.trim_end_matches('/')), + ProviderFamily::Anthropic => gateway_url.trim_end_matches('/').to_string(), + }; + plan.provider_overrides.insert( + family.id().into(), + json!({ + "baseUrl": relay_base_url, + "agentRuntime": {"id": "openclaw"} + }), + ); + match family { + ProviderFamily::OpenAi => plan.openai_upstream = Some(upstream.clone()), + ProviderFamily::Anthropic => plan.anthropic_upstream = Some(upstream.clone()), + } + plan.notes.push(format!( + "OpenClaw provider `{}` routed through Relay using {api}; original upstream preserved", + family.id() + )); + } + plan +} + +fn has_non_http_agent_model_runtime(config: &Value, family: ProviderFamily) -> bool { + let provider_prefix = format!("{}/", family.id()); + let has_override = |models: &Map| { + models.iter().any(|(model_ref, model)| { + model_ref.starts_with(&provider_prefix) + && model.get("agentRuntime").is_some() + && model.pointer("/agentRuntime/id").and_then(Value::as_str) != Some("openclaw") + }) + }; + + config + .pointer("/agents/defaults/models") + .and_then(Value::as_object) + .is_some_and(&has_override) + || config + .pointer("/agents/list") + .and_then(Value::as_array) + .is_some_and(|agents| { + agents.iter().any(|agent| { + agent + .get("models") + .and_then(Value::as_object) + .is_some_and(&has_override) + }) + }) +} + +fn unsupported_provider_reason(family: ProviderFamily, provider: Option<&Value>) -> Option { + let provider_value = provider?; + let Some(provider) = provider_value.as_object() else { + return Some("provider configuration is not an object".into()); + }; + if provider.contains_key("baseUrl") + && provider + .get("baseUrl") + .and_then(Value::as_str) + .is_none_or(|value| value.trim().is_empty()) + { + return Some("baseUrl is not a non-empty string".into()); + } + if provider + .get("baseUrl") + .and_then(Value::as_str) + .is_some_and(|value| value.contains("${")) + { + return Some( + "environment-substituted baseUrl cannot be preserved safely by the launcher".into(), + ); + } + if provider.contains_key("agentRuntime") + && provider + .get("agentRuntime") + .and_then(|runtime| runtime.get("id")) + .and_then(Value::as_str) + != Some("openclaw") + { + return Some("its explicit agent runtime is not the direct OpenClaw HTTP runtime".into()); + } + if provider + .get("models") + .and_then(Value::as_array) + .is_some_and(|models| { + models.iter().any(|model| { + model.as_object().is_some_and(|model| { + model.contains_key("api") + || model.contains_key("baseUrl") + || (model.contains_key("agentRuntime") + && model + .get("agentRuntime") + .and_then(|runtime| runtime.get("id")) + .and_then(Value::as_str) + != Some("openclaw")) + }) + }) + }) + { + return Some( + "model-level API, base URL, or agent-runtime overrides cannot be routed safely".into(), + ); + } + let api = provider.get("api").and_then(Value::as_str)?; + let supported = match family { + ProviderFamily::OpenAi => matches!(api, "openai-completions" | "openai-responses"), + ProviderFamily::Anthropic => api == "anthropic-messages", + }; + (!supported).then(|| format!("unsupported API adapter `{api}`")) +} + +fn provider_uses_api_key( + provider: Option<&Value>, + profiles: AuthProfileEvidence, + trusted_api_key: bool, +) -> Result<(), &'static str> { + let provider = provider.and_then(Value::as_object); + let explicit_api_key = provider.is_some_and(|provider| { + provider.get("auth").and_then(Value::as_str) == Some("api-key") + || provider.get("apiKey").is_some_and(|value| match value { + Value::String(value) => !value.trim().is_empty(), + Value::Object(_) => true, + _ => false, + }) + }); + if provider + .and_then(|provider| provider.get("auth")) + .and_then(Value::as_str) + .is_some_and(|auth| auth != "api-key") + { + return Err("its explicit authentication mode is not API-key based"); + } + match profiles { + AuthProfileEvidence::ApiKeyOnly => Ok(()), + AuthProfileEvidence::ApiKeyOrNone if explicit_api_key || trusted_api_key => Ok(()), + AuthProfileEvidence::ApiKeyOrNone => Err( + "some OpenClaw agents have no saved API-key profile and no shared API key was detected", + ), + AuthProfileEvidence::NonApiKeyOrMixed => Err( + "OpenClaw auth profiles include non-API-key or mixed credential types, so selection is ambiguous", + ), + AuthProfileEvidence::Unknown => { + Err("OpenClaw auth-profile state could not be verified as API-key-only") + } + AuthProfileEvidence::None if explicit_api_key || trusted_api_key => Ok(()), + AuthProfileEvidence::None => { + Err("no API-key-backed configuration or auth profile was detected") + } + } +} + +fn provider_map(config: &Value) -> Option<&Map> { + config + .get("models") + .and_then(Value::as_object) + .and_then(|models| models.get("providers")) + .and_then(Value::as_object) +} + +fn contains_provider_include(config: &Value) -> bool { + let Some(root) = config.as_object() else { + return false; + }; + root.contains_key("$include") + || ["models", "agents", "auth", "env"] + .into_iter() + .filter_map(|key| root.get(key)) + .any(contains_nested_include) +} + +fn contains_nested_include(config: &Value) -> bool { + match config { + Value::Object(object) => { + object.contains_key("$include") || object.values().any(contains_nested_include) + } + Value::Array(values) => values.iter().any(contains_nested_include), + _ => false, + } +} + +fn trusted_api_key_sources(argv: &[String], config: Option<&Value>) -> TrustedApiKeySet { + let state_dir = probe_state_dir(argv).ok(); + let has_key = |family: ProviderFamily| { + family.has_api_key_environment() + || config.is_some_and(|config| config_env_has_api_key(config, family)) + || state_dir + .as_ref() + .is_some_and(|state| dotenv_has_api_key(&state.join(".env"), family)) + }; + TrustedApiKeySet { + openai: has_key(ProviderFamily::OpenAi), + anthropic: has_key(ProviderFamily::Anthropic), + } +} + +fn config_env_has_api_key(config: &Value, family: ProviderFamily) -> bool { + let Some(env) = config.get("env").and_then(Value::as_object) else { + return false; + }; + env.iter() + .filter(|(name, _)| name.as_str() != "vars") + .any(|(name, value)| family.is_api_key_name(name) && nonempty_string(value)) + || env + .get("vars") + .and_then(Value::as_object) + .is_some_and(|vars| { + vars.iter() + .any(|(name, value)| family.is_api_key_name(name) && nonempty_string(value)) + }) +} + +fn dotenv_has_api_key(path: &Path, family: ProviderFamily) -> bool { + let Ok(contents) = std::fs::read_to_string(path) else { + return false; + }; + contents.lines().any(|line| { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + return false; + } + let line = line.strip_prefix("export ").unwrap_or(line).trim_start(); + let Some((name, value)) = line.split_once('=') else { + return false; + }; + family.is_api_key_name(name.trim()) && nonempty_dotenv_value(value) + }) +} + +fn nonempty_string(value: &Value) -> bool { + value.as_str().is_some_and(|value| !value.trim().is_empty()) +} + +fn nonempty_dotenv_value(value: &str) -> bool { + let value = value.trim(); + if value.starts_with('#') { + return false; + } + let value = if value.starts_with('\'') || value.starts_with('"') { + value + } else { + value.split(" #").next().unwrap_or(value).trim_end() + }; + !value.is_empty() && value != "\"\"" && value != "''" +} + +fn overlay_document( + source: Option<&Path>, + source_config: Option<&Value>, + providers: Map, + bridge_dir: &Path, + disable_embedded_relay: bool, +) -> Value { + let mut overlay = Map::new(); + if let Some(source) = source { + overlay.insert( + "$include".into(), + Value::String(source.display().to_string()), + ); + } + if !providers.is_empty() { + overlay.insert("models".into(), json!({"providers": providers})); + } + let mut load_paths = source_config + .and_then(|config| config.pointer("/plugins/load/paths")) + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + let bridge_path = bridge_dir.display().to_string(); + if !load_paths + .iter() + .any(|path| path.as_str() == Some(bridge_path.as_str())) + { + load_paths.push(Value::String(bridge_path)); + } + let mut entries = Map::from_iter([( + RELAY_OPENCLAW_BRIDGE_ID.to_string(), + json!({ + "enabled": true, + "config": {}, + "hooks": {"allowConversationAccess": true} + }), + )]); + if disable_embedded_relay { + entries.insert( + RELAY_OPENCLAW_PLUGIN_ID.to_string(), + json!({"enabled": false}), + ); + } + overlay.insert( + "plugins".into(), + json!({ + "enabled": true, + "load": {"paths": load_paths}, + "entries": entries + }), + ); + Value::Object(overlay) +} + +fn probe_relay_plugin_installed(argv: &[String], source_path: &Path) -> bool { + let args = ["plugins", "inspect", RELAY_OPENCLAW_PLUGIN_ID, "--json"].map(str::to_string); + run_read_only_probe(argv, source_path, &args).is_some_and(|output| output.status.success()) +} + +fn write_bridge_plugin() -> Result { + let root = std::env::temp_dir().join(format!( + "nemo-relay-openclaw-bridge-{}-{}", + std::process::id(), + unique_stamp()? + )); + let result = std::fs::create_dir_all(&root) + .and_then(|()| std::fs::write(root.join("package.json"), BRIDGE_PACKAGE_JSON)) + .and_then(|()| std::fs::write(root.join("openclaw.plugin.json"), BRIDGE_MANIFEST_JSON)) + .and_then(|()| std::fs::write(root.join("index.js"), BRIDGE_INDEX_JS)); + if let Err(error) = result { + let _ = std::fs::remove_dir_all(&root); + return Err(CliError::Launch(format!( + "could not create temporary OpenClaw CLI bridge: {error}" + ))); + } + Ok(root) +} + +fn add_invocation_metadata(gateway: &mut GatewayConfig) { + let mut metadata = match gateway.metadata.take() { + Some(Value::Object(metadata)) => metadata, + Some(metadata) => Map::from_iter([("nemo_relay_original_metadata".into(), metadata)]), + None => Map::new(), + }; + metadata.insert( + "nemo_relay_invocation".into(), + json!({"agent": "openclaw", "integration": "cli_launcher"}), + ); + gateway.metadata = Some(Value::Object(metadata)); +} + +fn read_source_config(path: &Path) -> Result<(Option, bool), CliError> { + let raw = match std::fs::read_to_string(path) { + Ok(raw) => raw, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok((None, false)), + Err(error) => { + return Err(CliError::Launch(format!( + "could not read OpenClaw config {}: {error}", + path.display() + ))); + } + }; + let value: Value = json5::from_str(&raw).map_err(|error| { + CliError::Launch(format!( + "invalid OpenClaw JSON5 config {}: {error}", + path.display() + )) + })?; + if !value.is_object() { + return Err(CliError::Launch(format!( + "OpenClaw config {} must contain a JSON5 object", + path.display() + ))); + } + Ok((Some(value), true)) +} + +fn resolve_config_path(argv: &[String]) -> Result { + if let Some(path) = nonempty_env_os(OPENCLAW_CONFIG_PATH) { + return resolve_openclaw_path(path); + } + let home = effective_home()?; + if let Some(state) = nonempty_env_os("OPENCLAW_STATE_DIR") { + return Ok(resolve_openclaw_path(state)?.join("openclaw.json")); + } + if let Some(profile) = command_profile(argv)? { + return Ok(home + .join(format!(".openclaw-{profile}")) + .join("openclaw.json")); + } + let primary = home.join(".openclaw").join("openclaw.json"); + Ok(primary) +} + +fn command_profile(argv: &[String]) -> Result, CliError> { + let mut profile = None; + let executable = openclaw_executable_index(argv).ok_or_else(|| { + CliError::Launch("could not locate the OpenClaw executable in the launch prefix".into()) + })?; + let command = first_openclaw_positional(argv, executable) + .map(|(index, _)| index) + .unwrap_or(argv.len()); + let mut index = executable + 1; + while index < command { + match argv[index].as_str() { + "--dev" if profile.is_none() => profile = Some("dev".to_string()), + "--profile" => { + let value = argv.get(index + 1).ok_or_else(|| { + CliError::Launch("OpenClaw --profile requires a profile name".into()) + })?; + profile = Some(value.clone()); + index += 1; + } + value if value.starts_with("--profile=") => { + profile = Some(value.trim_start_matches("--profile=").to_string()); + } + _ => {} + } + index += 1; + } + if let Some(value) = profile.as_deref() + && (value.is_empty() + || !value.chars().all(|character| { + character.is_ascii_alphanumeric() || matches!(character, '-' | '_') + })) + { + return Err(CliError::Launch(format!( + "invalid OpenClaw profile name `{value}`" + ))); + } + Ok(profile) +} + +fn openclaw_executable_index(argv: &[String]) -> Option { + if argv + .first() + .is_some_and(|value| is_openclaw_executable(value)) + { + return Some(0); + } + let launcher = argv + .first() + .and_then(|value| Path::new(value).file_name()) + .and_then(|value| value.to_str())?; + let start = match launcher { + "npx" | "npx.exe" | "bunx" | "bunx.exe" | "pnpx" | "pnpx.exe" => 1, + "npm" | "npm.exe" if matches!(argv.get(1).map(String::as_str), Some("exec" | "x")) => 2, + "pnpm" | "pnpm.exe" | "yarn" | "yarn.exe" + if matches!(argv.get(1).map(String::as_str), Some("exec" | "dlx")) => + { + 2 + } + _ => return None, + }; + for (index, value) in argv.iter().enumerate().skip(start) { + if value == "--" || matches!(value.as_str(), "--yes" | "-y" | "--quiet") { + continue; + } + // The first package/command token in a supported wrapper must be OpenClaw; never scan + // later user arguments for a matching basename. + return is_openclaw_executable(value).then_some(index); + } + None +} + +fn first_openclaw_positional(argv: &[String], executable: usize) -> Option<(usize, &str)> { + let mut index = executable + 1; + while index < argv.len() { + match argv[index].as_str() { + "--profile" | "--log-level" | "--container" if argv.get(index + 1).is_some() => { + index += 2; + } + value + if value.starts_with("--profile=") + || value.starts_with("--log-level=") + || value.starts_with("--container=") => + { + index += 1; + } + value if value.starts_with('-') => index += 1, + value => return Some((index, value)), + } + } + None +} + +fn effective_home() -> Result { + let value = nonempty_env_os("OPENCLAW_HOME") + .or_else(|| nonempty_env_os("HOME")) + .or_else(|| nonempty_env_os("USERPROFILE")); + match value { + Some(value) => resolve_openclaw_path(value), + None => std::env::current_dir().map_err(CliError::from), + } +} + +fn resolve_openclaw_path(value: OsString) -> Result { + let text = value.to_string_lossy(); + let path = if text == "~" || text.starts_with("~/") || text.starts_with("~\\") { + let home = nonempty_env_os("HOME") + .or_else(|| nonempty_env_os("USERPROFILE")) + .ok_or_else(|| { + CliError::Launch("cannot expand OpenClaw path without a home directory".into()) + })?; + PathBuf::from(home).join(text.trim_start_matches('~').trim_start_matches(['/', '\\'])) + } else { + PathBuf::from(value) + }; + if path.is_absolute() { + Ok(path) + } else { + Ok(std::env::current_dir()?.join(path)) + } +} + +fn probe_auth_profiles( + argv: &[String], + source_path: &Path, + config: Option<&Value>, + family: ProviderFamily, +) -> AuthProfileEvidence { + let mut aggregate = None; + for agent in auth_probe_agents(argv, config) { + let evidence = probe_one_auth_profile_set(argv, source_path, family, agent.as_deref()); + aggregate = Some(match aggregate { + None => evidence, + Some(current) => merge_auth_profile_evidence(current, evidence), + }); + } + aggregate.unwrap_or(AuthProfileEvidence::Unknown) +} + +fn probe_one_auth_profile_set( + argv: &[String], + source_path: &Path, + family: ProviderFamily, + agent: Option<&str>, +) -> AuthProfileEvidence { + let mut args = vec!["models".into(), "auth".into()]; + if let Some(agent) = agent { + args.extend(["--agent".into(), agent.to_string()]); + } + args.push("list".into()); + args.extend(["--provider".into(), family.id().into(), "--json".into()]); + let Some(output) = run_read_only_probe(argv, source_path, &args) else { + return AuthProfileEvidence::Unknown; + }; + if !output.status.success() { + return AuthProfileEvidence::Unknown; + } + let Ok(value) = serde_json::from_slice::(&output.stdout) else { + return AuthProfileEvidence::Unknown; + }; + let Some(profiles) = value.get("profiles").and_then(Value::as_array) else { + return AuthProfileEvidence::Unknown; + }; + if profiles.is_empty() { + return AuthProfileEvidence::None; + } + if profiles + .iter() + .all(|profile| profile.get("type").and_then(Value::as_str) == Some("api_key")) + { + AuthProfileEvidence::ApiKeyOnly + } else { + AuthProfileEvidence::NonApiKeyOrMixed + } +} + +fn auth_probe_agents(argv: &[String], config: Option<&Value>) -> Vec> { + if let Some(agent) = selected_agent_id(argv) { + return vec![Some(agent.to_string())]; + } + // Session keys and workspace selection can choose a non-default agent without an explicit + // `--agent`. In that ambiguous case, aggregate every configured auth store rather than + // redirecting globally based only on the default agent. + let mut agents = vec![None]; + if let Some(configured) = config + .and_then(|config| config.pointer("/agents/list")) + .and_then(Value::as_array) + { + for agent in configured { + let Some(id) = agent.get("id").and_then(Value::as_str) else { + continue; + }; + if !id.trim().is_empty() && !agents.iter().flatten().any(|existing| existing == id) { + agents.push(Some(id.to_string())); + } + } + } + agents +} + +fn merge_auth_profile_evidence( + left: AuthProfileEvidence, + right: AuthProfileEvidence, +) -> AuthProfileEvidence { + use AuthProfileEvidence::{ApiKeyOnly, ApiKeyOrNone, NonApiKeyOrMixed, None, Unknown}; + match (left, right) { + (Unknown, _) | (_, Unknown) => Unknown, + (NonApiKeyOrMixed, _) | (_, NonApiKeyOrMixed) => NonApiKeyOrMixed, + (ApiKeyOnly, ApiKeyOnly) => ApiKeyOnly, + (None, None) => None, + (ApiKeyOrNone, ApiKeyOnly | None | ApiKeyOrNone) + | (ApiKeyOnly | None, ApiKeyOrNone) + | (ApiKeyOnly, None) + | (None, ApiKeyOnly) => ApiKeyOrNone, + } +} + +fn selected_agent_id(argv: &[String]) -> Option<&str> { + let executable = openclaw_executable_index(argv)?; + let (command_index, command) = first_openclaw_positional(argv, executable)?; + if command != "agent" { + return None; + } + let mut index = command_index + 1; + while index < argv.len() { + match argv[index].as_str() { + "--agent" => return argv.get(index + 1).map(String::as_str), + value if value.starts_with("--agent=") => { + return Some(value.trim_start_matches("--agent=")); + } + _ => index += 1, + } + } + None +} + +fn run_read_only_probe(argv: &[String], source_path: &Path, args: &[String]) -> Option { + let executable = openclaw_executable_index(argv)?; + let mut command = Command::new(argv.first()?); + if executable > 0 { + command.args(&argv[1..=executable]); + } + command + .args(args) + .env(OPENCLAW_CONFIG_PATH, source_path) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + if std::env::var_os("OPENCLAW_STATE_DIR").is_none() + && let Ok(state_dir) = probe_state_dir(argv) + { + command.env("OPENCLAW_STATE_DIR", state_dir); + } + command.output().ok() +} + +fn probe_state_dir(argv: &[String]) -> Result { + if let Some(path) = nonempty_env_os("OPENCLAW_STATE_DIR") { + return resolve_openclaw_path(path); + } + let home = effective_home()?; + if let Some(profile) = command_profile(argv)? { + return Ok(home.join(format!(".openclaw-{profile}"))); + } + let primary = home.join(".openclaw"); + Ok(primary) +} + +fn temporary_overlay_path(source_path: &Path) -> Result { + let stamp = unique_stamp()?; + let parent = source_path.parent().ok_or_else(|| { + CliError::Launch(format!( + "OpenClaw config path {} has no parent directory", + source_path.display() + )) + })?; + std::fs::create_dir_all(parent)?; + Ok(parent.join(format!( + ".openclaw.nemo-relay-{}-{stamp}.json5", + std::process::id() + ))) +} + +fn unique_stamp() -> Result { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|error| CliError::Launch(error.to_string())) + .map(|duration| duration.as_nanos()) +} + +fn nonempty_env_os(name: &str) -> Option { + std::env::var_os(name).filter(|value| { + let value = value.to_string_lossy(); + let value = value.trim(); + !value.is_empty() && value != "undefined" && value != "null" + }) +} + +#[cfg(test)] +#[path = "../tests/coverage/openclaw_tests.rs"] +mod tests; diff --git a/crates/cli/src/server.rs b/crates/cli/src/server.rs index 386ab8a26..a145a73bb 100644 --- a/crates/cli/src/server.rs +++ b/crates/cli/src/server.rs @@ -12,13 +12,8 @@ use axum::extract::{DefaultBodyLimit, State}; use axum::http::HeaderMap; use axum::routing::{get, post}; use axum::{Json, Router}; -use nemo_relay::plugin::dynamic::{ - DynamicPluginKind, NativePluginActivation, NativePluginLoadSpec, WorkerPluginActivation, - WorkerPluginLoadSpec, load_native_plugins, load_worker_plugins, -}; -use nemo_relay::plugin::{ - PluginComponentSpec, PluginConfig, clear_plugin_configuration, initialize_plugins_exact, -}; +use nemo_relay::plugin::PluginConfig; +use nemo_relay::plugin::dynamic::{DynamicPluginActivationSpec, PluginHostActivation}; use nemo_relay_adaptive::plugin_component::register_adaptive_component; use nemo_relay_pii_redaction::component::register_pii_redaction_component; use reqwest::Client; @@ -152,7 +147,7 @@ async fn serve_listener_with_dynamic_inner( shutdown_mode: Option, ) -> Result<(), CliError> { let plugin_activation = - PluginActivation::initialize(config.plugin_config.clone(), dynamic_plugins).await?; + initialize_plugin_host(config.plugin_config.clone(), dynamic_plugins).await?; let state = AppState::new(config); let sessions = state.sessions.clone(); let last_activity = state.last_activity.clone(); @@ -187,7 +182,13 @@ async fn serve_listener_with_dynamic_inner( }; let close_result = sessions.close_all("gateway_shutdown").await; let flush_result = nemo_relay::api::runtime::flush_subscribers().map_err(CliError::from); - let clear_result = plugin_activation.clear(); + let clear_result = plugin_activation + .map(|activation| { + activation + .clear() + .map_err(|error| CliError::Config(format!("plugin teardown failed: {error}"))) + }) + .unwrap_or(Ok(())); if let Err(serve_error) = serve_result { if let Err(close_error) = close_result { eprintln!("session teardown failed after server error: {close_error}"); @@ -274,6 +275,7 @@ fn router_with_state(state: AppState) -> Router { .route("/hooks/codex", post(codex_hook)) .route("/hooks/claude-code", post(claude_code_hook)) .route("/hooks/hermes", post(hermes_hook)) + .route("/hooks/openclaw", post(openclaw_hook)) .route("/responses", post(gateway::passthrough)) .route("/chat/completions", post(gateway::passthrough)) .route("/models", get(gateway::models)) @@ -317,129 +319,46 @@ async fn idle_shutdown_future( } } -struct PluginActivation { - active: bool, - native: Option, - worker: Option, -} - -impl PluginActivation { - async fn initialize( - config: Option, - dynamic_plugins: Vec, - ) -> Result { - if config.is_none() && dynamic_plugins.is_empty() { - return Ok(Self { - active: false, - native: None, - worker: None, - }); - }; - register_adaptive_component().map_err(|error| { - CliError::Config(format!("adaptive plugin registration failed: {error}")) - })?; - register_pii_redaction_component().map_err(|error| { - CliError::Config(format!("PII redaction plugin registration failed: {error}")) - })?; - 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 - )) - })?; - Ok(NativePluginLoadSpec { - plugin_id: plugin.plugin_id.clone(), - manifest_ref, - }) - }) - .collect::, CliError>>()?; - let worker_specs = dynamic_plugins - .iter() - .filter(|plugin| plugin.kind == DynamicPluginKind::Worker) - .map(|plugin| { - let manifest_ref = plugin.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(), - config: plugin.config.clone(), - }) +async fn initialize_plugin_host( + config: Option, + dynamic_plugins: Vec, +) -> Result, CliError> { + if config.is_none() && dynamic_plugins.is_empty() { + return Ok(None); + } + register_adaptive_component().map_err(|error| { + CliError::Config(format!("adaptive plugin registration failed: {error}")) + })?; + register_pii_redaction_component().map_err(|error| { + CliError::Config(format!("PII redaction plugin registration failed: {error}")) + })?; + let plugin_config: PluginConfig = match config { + Some(config) => serde_json::from_value(config) + .map_err(|error| CliError::Config(format!("invalid plugin config: {error}")))?, + None => PluginConfig::default(), + }; + let specs = dynamic_plugins + .into_iter() + .map(|plugin| { + let manifest_ref = plugin.manifest_ref.ok_or_else(|| { + CliError::Config(format!( + "dynamic plugin '{}' has no manifest_ref in lifecycle state", + plugin.plugin_id + )) + })?; + Ok(DynamicPluginActivationSpec { + plugin_id: plugin.plugin_id, + kind: plugin.kind, + manifest_ref, + environment_ref: plugin.environment_ref, + config: plugin.config, }) - .collect::, CliError>>()?; - let native = - if native_specs.is_empty() { - None - } else { - Some(load_native_plugins(native_specs).map_err(|error| { - CliError::Config(format!("native plugin load failed: {error}")) - })?) - }; - let worker = - if worker_specs.is_empty() { - None - } else { - Some(load_worker_plugins(worker_specs).map_err(|error| { - CliError::Config(format!("worker plugin load failed: {error}")) - })?) - }; - // Gateway already resolved its config; activate exactly (no re-discovery). - let mut plugin_config: PluginConfig = match config { - Some(config) => serde_json::from_value(config) - .map_err(|error| CliError::Config(format!("invalid plugin config: {error}")))?, - None => PluginConfig::default(), - }; - plugin_config - .components - .extend( - dynamic_plugins - .into_iter() - .map(|plugin| PluginComponentSpec { - kind: plugin.plugin_id, - enabled: true, - config: plugin.config, - }), - ); - initialize_plugins_exact(plugin_config) - .await - .map_err(|error| CliError::Config(format!("plugin activation failed: {error}")))?; - Ok(Self { - active: true, - native, - worker, }) - } - - fn clear(mut self) -> Result<(), CliError> { - let result = if self.active { - self.active = false; - clear_plugin_configuration() - .map_err(|error| CliError::Config(format!("plugin teardown failed: {error}")))?; - Ok(()) - } else { - Ok(()) - }; - self.native.take(); - self.worker.take(); - result - } -} - -impl Drop for PluginActivation { - fn drop(&mut self) { - if self.active { - let _ = clear_plugin_configuration(); - self.active = false; - } - } + .collect::, CliError>>()?; + let (activation, _) = PluginHostActivation::activate(plugin_config, specs) + .await + .map_err(|error| CliError::Config(format!("plugin activation failed: {error}")))?; + Ok(Some(activation)) } // Normalizes a Codex hook payload, applies all resulting events before responding, and returns the @@ -493,6 +412,23 @@ async fn hermes_hook( Ok(Json(outcome.response)) } +// Handles events from the temporary OpenClaw bridge injected by the CLI wrapper. The bridge does +// not host Relay or mutate tool results; provider requests still enter the managed LLM gateway. +async fn openclaw_hook( + State(state): State, + headers: HeaderMap, + payload: Result, JsonRejection>, +) -> Result, CliError> { + state.touch(); + let Json(payload) = payload.map_err(hook_payload_rejection)?; + let outcome = crate::adapters::openclaw::adapt(payload, &headers); + state + .sessions + .apply_events(&headers, outcome.events) + .await?; + Ok(Json(outcome.response)) +} + fn hook_payload_rejection(rejection: JsonRejection) -> CliError { if rejection.status() == axum::http::StatusCode::PAYLOAD_TOO_LARGE { CliError::PayloadTooLarge(rejection.to_string()) diff --git a/crates/cli/src/setup.rs b/crates/cli/src/setup.rs index 9a717cc36..05a58ed54 100644 --- a/crates/cli/src/setup.rs +++ b/crates/cli/src/setup.rs @@ -270,6 +270,7 @@ fn ask_agents( CodingAgent::ClaudeCode, CodingAgent::Codex, CodingAgent::Hermes, + CodingAgent::OpenClaw, ]; let labels: Vec = all_supported .iter() diff --git a/crates/cli/src/setup/model.rs b/crates/cli/src/setup/model.rs index fe03003e7..e2dcfed4a 100644 --- a/crates/cli/src/setup/model.rs +++ b/crates/cli/src/setup/model.rs @@ -91,6 +91,7 @@ pub(crate) fn detect_installed_agents_in(path_var: Option<&std::ffi::OsStr>) -> (CodingAgent::ClaudeCode, "claude"), (CodingAgent::Codex, "codex"), (CodingAgent::Hermes, "hermes"), + (CodingAgent::OpenClaw, "openclaw"), ]; candidates .into_iter() @@ -409,6 +410,7 @@ pub(super) fn read_agents_from_doc(doc: &DocumentMut) -> Vec { "claude" => Some(CodingAgent::ClaudeCode), "codex" => Some(CodingAgent::Codex), "hermes" => Some(CodingAgent::Hermes), + "openclaw" => Some(CodingAgent::OpenClaw), _ => None, }; if let Some(agent) = agent { @@ -423,6 +425,7 @@ pub(super) fn agent_key_and_command(agent: CodingAgent) -> (&'static str, &'stat CodingAgent::ClaudeCode => ("claude", "claude"), CodingAgent::Codex => ("codex", "codex"), CodingAgent::Hermes => ("hermes", "hermes"), + CodingAgent::OpenClaw => ("openclaw", "openclaw"), } } diff --git a/crates/cli/tests/cli_tests.rs b/crates/cli/tests/cli_tests.rs index 91dfa0b71..13c4469da 100644 --- a/crates/cli/tests/cli_tests.rs +++ b/crates/cli/tests/cli_tests.rs @@ -1172,7 +1172,7 @@ fn cli_help_lists_easy_path_agent_shortcuts() { let output = Command::new(gateway_bin()).arg("--help").output().unwrap(); let stdout = String::from_utf8_lossy(&output.stdout); - for agent in ["claude", "codex", "hermes"] { + for agent in ["claude", "codex", "hermes", "openclaw"] { assert!( stdout.contains(&format!(" {agent}")), "expected `--help` to list `{agent}` subcommand, got:\n{stdout}" diff --git a/crates/cli/tests/coverage/adapters_tests.rs b/crates/cli/tests/coverage/adapters_tests.rs index 685f3c23f..b292ef717 100644 --- a/crates/cli/tests/coverage/adapters_tests.rs +++ b/crates/cli/tests/coverage/adapters_tests.rs @@ -5,7 +5,63 @@ use axum::http::HeaderMap; use serde_json::json; use super::*; -use crate::adapters::{claude_code, codex, hermes}; +use crate::adapters::{claude_code, codex, hermes, openclaw}; + +#[test] +fn maps_openclaw_bridge_lifecycle_llm_and_tool_events() { + let headers = HeaderMap::new(); + + let started = openclaw::adapt( + json!({ + "session_id": "openclaw-session", + "hook_event_name": "session_start" + }), + &headers, + ); + assert!(matches!( + &started.events[0], + NormalizedEvent::AgentStarted(event) + if event.agent_kind == AgentKind::OpenClaw + && event.session_id == "openclaw-session" + )); + + let hint = openclaw::adapt( + json!({ + "session_id": "openclaw-session", + "hook_event_name": "preLlmCall", + "request_id": "run-1", + "model": "claude-test" + }), + &headers, + ); + assert!(matches!( + &hint.events[0], + NormalizedEvent::LlmHint(event) + if event.agent_kind == AgentKind::OpenClaw + && event.request_id.as_deref() == Some("run-1") + && event.model.as_deref() == Some("claude-test") + )); + + let tool = openclaw::adapt( + json!({ + "session_id": "openclaw-session", + "hook_event_name": "tool_end", + "tool_call_id": "tool-1", + "tool_name": "exec", + "tool_input": {"command": "pwd"}, + "tool_output": {"content": [{"type": "text", "text": "/workspace"}]} + }), + &headers, + ); + assert!(matches!( + &tool.events[0], + NormalizedEvent::ToolEnded(event) + if event.agent_kind == AgentKind::OpenClaw + && event.tool_call_id == "tool-1" + && event.tool_name == "exec" + && event.arguments == json!({"command": "pwd"}) + )); +} #[test] fn maps_claude_canonical_tool_payload() { diff --git a/crates/cli/tests/coverage/config_tests.rs b/crates/cli/tests/coverage/config_tests.rs index d1a3fc3d3..e207fb111 100644 --- a/crates/cli/tests/coverage/config_tests.rs +++ b/crates/cli/tests/coverage/config_tests.rs @@ -295,6 +295,8 @@ fn agent_and_gateway_mode_arguments_are_stable() { assert_eq!(CodingAgent::ClaudeCode.hook_path(), "/hooks/claude-code"); assert_eq!(CodingAgent::Codex.hook_path(), "/hooks/codex"); assert_eq!(CodingAgent::Hermes.hook_path(), "/hooks/hermes"); + assert_eq!(CodingAgent::OpenClaw.hook_path(), "/hooks/openclaw"); + assert_eq!(CodingAgent::OpenClaw.as_arg(), "openclaw"); assert_eq!(GatewayMode::HookOnly.as_arg(), "hook-only"); assert_eq!(GatewayMode::Passthrough.as_arg(), "passthrough"); assert_eq!(GatewayMode::Required.as_arg(), "required"); @@ -309,6 +311,10 @@ fn agent_inference_uses_executable_basename() { assert_eq!(CodingAgent::infer("codex"), Some(CodingAgent::Codex)); assert_eq!(CodingAgent::infer("cursor-agent"), None); assert_eq!(CodingAgent::infer("hermes"), Some(CodingAgent::Hermes)); + assert_eq!( + CodingAgent::infer("/opt/bin/openclaw"), + Some(CodingAgent::OpenClaw) + ); assert_eq!(CodingAgent::infer("wrapper"), None); } @@ -335,6 +341,9 @@ command = "codex --approval-mode never" [agents.hermes] command = "hermes --yolo chat" + +[agents.openclaw] +command = "openclaw tui" "#, ) .unwrap(); @@ -367,6 +376,10 @@ command = "hermes --yolo chat" resolved.agents.hermes.command.as_deref(), Some("hermes --yolo chat") ); + assert_eq!( + resolved.agents.openclaw.command.as_deref(), + Some("openclaw tui") + ); } #[test] diff --git a/crates/cli/tests/coverage/launcher_tests.rs b/crates/cli/tests/coverage/launcher_tests.rs index 1615642cd..3f51330d4 100644 --- a/crates/cli/tests/coverage/launcher_tests.rs +++ b/crates/cli/tests/coverage/launcher_tests.rs @@ -227,6 +227,7 @@ fn default_and_configured_command_helpers_cover_empty_and_all_agents() { assert_eq!(default_command_for(CodingAgent::ClaudeCode), "claude"); assert_eq!(default_command_for(CodingAgent::Codex), "codex"); assert_eq!(default_command_for(CodingAgent::Hermes), "hermes"); + assert_eq!(default_command_for(CodingAgent::OpenClaw), "openclaw"); let agents = AgentConfigs { codex: AgentCommandConfig { @@ -238,6 +239,33 @@ fn default_and_configured_command_helpers_cover_empty_and_all_agents() { assert!(configured_command(CodingAgent::Codex, &agents).is_none()); } +#[test] +fn resolves_configured_openclaw_command_and_inference() { + let agents = AgentConfigs { + openclaw: AgentCommandConfig { + command: Some("openclaw tui".into()), + hooks_path: None, + }, + ..AgentConfigs::default() + }; + let command = RunCommand { + agent: Some(CodingAgent::OpenClaw), + config: None, + openai_base_url: None, + anthropic_base_url: None, + session_metadata: None, + plugin_config_path: None, + dry_run: true, + print: false, + command: vec![], + }; + + let (agent, argv) = resolve_agent_and_argv(&command, &agents).unwrap(); + + assert_eq!(agent, CodingAgent::OpenClaw); + assert_eq!(argv, ["openclaw", "tui"]); +} + #[test] fn prepares_codex_config_overrides() { let resolved = ResolvedConfig { @@ -771,6 +799,7 @@ fn hermes_restore_reports_restore_and_temporary_removal_failures() { argv: vec![], env: vec![], temp_dirs: vec![], + temp_files: vec![], hermes_restore: Some(HermesRestore { path: temp.path().join("config.yaml"), backup_path: Some(temp.path().join("missing-backup.yaml")), @@ -788,6 +817,7 @@ fn hermes_restore_reports_restore_and_temporary_removal_failures() { argv: vec![], env: vec![], temp_dirs: vec![], + temp_files: vec![], hermes_restore: Some(HermesRestore { path: hooks_path, backup_path: None, @@ -800,6 +830,44 @@ fn hermes_restore_reports_restore_and_temporary_removal_failures() { assert!(error.contains("failed to remove temporary Hermes hooks")); } +#[test] +fn restore_removes_temporary_launcher_files() { + let temp = tempfile::tempdir().unwrap(); + let overlay = temp.path().join("overlay.json5"); + std::fs::write(&overlay, "{}").unwrap(); + let prepared = PreparedRun { + argv: vec![], + env: vec![], + temp_dirs: vec![], + temp_files: vec![overlay.clone()], + hermes_restore: None, + notes: vec![], + }; + + prepared.restore().unwrap(); + + assert!(!overlay.exists()); +} + +#[test] +fn prepared_run_drop_defensively_removes_temporary_launcher_files() { + let temp = tempfile::tempdir().unwrap(); + let overlay = temp.path().join("overlay.json5"); + std::fs::write(&overlay, "{}").unwrap(); + let prepared = PreparedRun { + argv: vec![], + env: vec![], + temp_dirs: vec![], + temp_files: vec![overlay.clone()], + hermes_restore: None, + notes: vec![], + }; + + drop(prepared); + + assert!(!overlay.exists()); +} + #[test] fn hook_backup_and_write_helpers_cover_missing_existing_and_toml_escaping() { let temp = tempfile::tempdir().unwrap(); @@ -1093,6 +1161,117 @@ async fn execute_live_run_restores_hermes_hooks_when_health_check_fails() { assert_eq!(std::fs::read_to_string(&hooks_path).unwrap(), original); } +#[tokio::test] +async fn execute_live_run_removes_overlay_when_child_spawn_fails() { + let temp = tempfile::tempdir().unwrap(); + let overlay = temp.path().join("openclaw-overlay.json5"); + std::fs::write(&overlay, "{}").unwrap(); + let prepared = PreparedRun { + argv: vec![temp.path().join("missing-openclaw").display().to_string()], + env: vec![], + temp_dirs: vec![], + temp_files: vec![overlay.clone()], + hermes_restore: None, + notes: vec![], + }; + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let gateway_url = format!("http://{}", listener.local_addr().unwrap()); + + execute_live_run(listener, GatewayConfig::default(), &gateway_url, prepared) + .await + .unwrap_err(); + + assert!(!overlay.exists()); +} + +#[cfg(unix)] +#[tokio::test] +async fn execute_live_run_removes_overlay_after_launcher_shutdown_signal() { + let temp = tempfile::tempdir().unwrap(); + let overlay = temp.path().join("openclaw-overlay.json5"); + std::fs::write(&overlay, "{}").unwrap(); + let child = temp.path().join("openclaw"); + let started = temp.path().join("child-started"); + let stopped = temp.path().join("child-stopped"); + std::fs::write( + &child, + format!( + "#!/bin/sh\ntrap \"touch '{}'; exit 0\" TERM INT\ntouch '{}'\nwhile :; do sleep 1; done\n", + stopped.display(), + started.display(), + ), + ) + .unwrap(); + make_executable(&child); + let prepared = PreparedRun { + argv: vec![child.display().to_string()], + env: vec![], + temp_dirs: vec![], + temp_files: vec![overlay.clone()], + hermes_restore: None, + notes: vec![], + }; + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let gateway_url = format!("http://{}", listener.local_addr().unwrap()); + let started_for_signal = started.clone(); + + let code = tokio::time::timeout( + Duration::from_secs(10), + execute_live_run_with_shutdown( + listener, + GatewayConfig::default(), + &gateway_url, + prepared, + async move { + loop { + if started_for_signal.exists() { + return Ok(()); + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }, + ), + ) + .await + .expect("test child did not start and stop before timeout") + .unwrap(); + + assert_eq!(code, ExitCode::SUCCESS); + assert!(started.exists()); + assert!(stopped.exists()); + assert!(!overlay.exists()); +} + +#[tokio::test] +async fn execute_live_run_removes_overlay_when_shutdown_precedes_health() { + let temp = tempfile::tempdir().unwrap(); + let overlay = temp.path().join("openclaw-overlay.json5"); + std::fs::write(&overlay, "{}").unwrap(); + let prepared = PreparedRun { + argv: vec![temp.path().join("unused-openclaw").display().to_string()], + env: vec![], + temp_dirs: vec![], + temp_files: vec![overlay.clone()], + hermes_restore: None, + notes: vec![], + }; + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let gateway_url = format!("http://{}", listener.local_addr().unwrap()); + + let code = execute_live_run_with_shutdown( + listener, + GatewayConfig::default(), + &gateway_url, + prepared, + std::future::ready(Ok(())), + ) + .await + .unwrap(); + + assert_eq!(code, ExitCode::FAILURE); + assert!(!overlay.exists()); +} + #[cfg(unix)] fn make_executable(path: &Path) { use std::os::unix::fs::PermissionsExt; diff --git a/crates/cli/tests/coverage/openclaw_tests.rs b/crates/cli/tests/coverage/openclaw_tests.rs new file mode 100644 index 000000000..74f0dc6be --- /dev/null +++ b/crates/cli/tests/coverage/openclaw_tests.rs @@ -0,0 +1,753 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use super::*; +use std::ffi::{OsStr, OsString}; + +struct EnvScope { + _guard: std::sync::MutexGuard<'static, ()>, + values: Vec<(OsString, Option)>, +} + +impl EnvScope { + fn provider_test(values: &[(&str, Option<&OsStr>)]) -> Self { + let guard = crate::test_support::ENV_TEST_LOCK + .lock() + .unwrap_or_else(|error| error.into_inner()); + let mut names = std::env::vars_os() + .map(|(name, _)| name) + .filter(|name| { + let name = name.to_string_lossy(); + name == "OPENAI_API_KEY" + || name == "OPENAI_API_KEYS" + || name == "OPENCLAW_LIVE_OPENAI_KEY" + || name.starts_with("OPENAI_API_KEY_") + || name == "ANTHROPIC_API_KEY" + || name == "ANTHROPIC_API_KEYS" + || name == "OPENCLAW_LIVE_ANTHROPIC_KEY" + || name.starts_with("ANTHROPIC_API_KEY_") + || name == OPENCLAW_CONFIG_PATH + || name == "OPENCLAW_INCLUDE_ROOTS" + || name == "OPENCLAW_STATE_DIR" + || name == "OPENCLAW_HOME" + }) + .collect::>(); + names.extend(values.iter().map(|(name, _)| OsString::from(name))); + names.sort(); + names.dedup(); + let previous = names + .iter() + .map(|name| (name.clone(), std::env::var_os(name))) + .collect::>(); + for name in &names { + unsafe { std::env::remove_var(name) }; + } + for (name, value) in values { + if let Some(value) = value { + unsafe { std::env::set_var(name, value) }; + } + } + Self { + _guard: guard, + values: previous, + } + } +} + +impl Drop for EnvScope { + fn drop(&mut self) { + for (name, value) in self.values.drain(..) { + unsafe { + match value { + Some(value) => std::env::set_var(name, value), + None => std::env::remove_var(name), + } + } + } + } +} + +#[test] +fn forces_model_serving_commands_local_and_rejects_remote_or_detached_paths() { + let mut default = vec!["openclaw".into()]; + normalize_foreground_argv(&mut default).unwrap(); + assert_eq!(default, ["openclaw", "tui", "--local"]); + + let mut agent = vec![ + "openclaw".into(), + "agent".into(), + "--message".into(), + "hello".into(), + ]; + normalize_foreground_argv(&mut agent).unwrap(); + assert_eq!(agent[2], "--local"); + + let mut tui = vec![ + "npx".into(), + "openclaw".into(), + "tui".into(), + "--message".into(), + "hi".into(), + ]; + normalize_foreground_argv(&mut tui).unwrap(); + assert_eq!(tui[3], "--local"); + + let mut foreground = vec!["openclaw".into(), "gateway".into(), "run".into()]; + normalize_foreground_argv(&mut foreground).unwrap(); + assert_eq!(foreground, ["openclaw", "gateway", "run"]); + + let mut message_named_openclaw = vec![ + "openclaw".into(), + "agent".into(), + "--message".into(), + "openclaw".into(), + "--json".into(), + ]; + normalize_foreground_argv(&mut message_named_openclaw).unwrap(); + assert_eq!(message_named_openclaw[2], "--local"); + + let mut root_option = vec![ + "openclaw".into(), + "--log-level".into(), + "debug".into(), + "agent".into(), + ]; + normalize_foreground_argv(&mut root_option).unwrap(); + assert_eq!(root_option[4], "--local"); + + let remote = normalize_foreground_argv(&mut vec![ + "openclaw".into(), + "tui".into(), + "--url=ws://remote.example".into(), + ]) + .unwrap_err() + .to_string(); + assert!(remote.contains("remote TUI")); + + let detached = normalize_foreground_argv(&mut vec![ + "openclaw".into(), + "gateway".into(), + "start".into(), + ]) + .unwrap_err() + .to_string(); + assert!(detached.contains("foreground `gateway run`")); + + let container = normalize_foreground_argv(&mut vec![ + "openclaw".into(), + "--container".into(), + "sandbox".into(), + "agent".into(), + ]) + .unwrap_err() + .to_string(); + assert!(container.contains("host-side temporary provider overlay")); + + let mut profiled = vec!["openclaw".into(), "--profile".into(), "work".into()]; + normalize_foreground_argv(&mut profiled).unwrap(); + assert_eq!( + profiled, + ["openclaw", "--profile", "work", "tui", "--local"] + ); + + let mut command_named_profile = vec![ + "openclaw".into(), + "--profile".into(), + "agent".into(), + "tui".into(), + ]; + normalize_foreground_argv(&mut command_named_profile).unwrap(); + assert_eq!( + command_named_profile, + ["openclaw", "--profile", "agent", "tui", "--local"] + ); + assert_eq!( + command_profile(&command_named_profile).unwrap().as_deref(), + Some("agent") + ); + + let admin = normalize_foreground_argv(&mut vec![ + "openclaw".into(), + "plugins".into(), + "enable".into(), + "nemo-relay".into(), + ]) + .unwrap_err() + .to_string(); + assert!(admin.contains("run configuration, plugin, model-management")); +} + +#[test] +fn resolves_profile_config_after_environment_precedence() { + let temp = tempfile::tempdir().unwrap(); + let _env = EnvScope::provider_test(&[("OPENCLAW_HOME", Some(temp.path().as_os_str()))]); + + assert_eq!( + resolve_config_path(&["openclaw".into(), "--profile=work".into()]).unwrap(), + temp.path().join(".openclaw-work/openclaw.json") + ); + assert_eq!( + resolve_config_path(&["openclaw".into(), "--dev".into()]).unwrap(), + temp.path().join(".openclaw-dev/openclaw.json") + ); +} + +#[test] +fn routes_anthropic_and_openai_completions_with_original_upstreams() { + let _env = EnvScope::provider_test(&[]); + let config: Value = json5::from_str( + r#"{ + // Both providers are explicitly API-key-backed. + models: { providers: { + anthropic: { + baseUrl: "https://anthropic.internal.example", + api: "anthropic-messages", + apiKey: { source: "env", provider: "default", id: "ANTHROPIC_API_KEY" }, + }, + openai: { + baseUrl: "https://openai.internal.example/v1", + api: "openai-completions", + auth: "api-key", + }, + } }, + }"#, + ) + .unwrap(); + + let plan = build_routing_plan(Some(&config), "http://127.0.0.1:43123"); + + assert_eq!( + plan.anthropic_upstream.as_deref(), + Some("https://anthropic.internal.example") + ); + assert_eq!( + plan.openai_upstream.as_deref(), + Some("https://openai.internal.example/v1") + ); + assert_eq!( + plan.provider_overrides["anthropic"]["baseUrl"], + "http://127.0.0.1:43123" + ); + assert_eq!( + plan.provider_overrides["anthropic"]["agentRuntime"]["id"], + "openclaw" + ); + assert_eq!( + plan.provider_overrides["openai"]["baseUrl"], + "http://127.0.0.1:43123/v1" + ); + assert_eq!( + plan.provider_overrides["openai"]["agentRuntime"]["id"], + "openclaw" + ); + assert!( + plan.notes + .iter() + .any(|note| note.contains("openai-completions")) + ); + assert!( + plan.notes + .iter() + .any(|note| note.contains("anthropic-messages")) + ); +} + +#[test] +fn routes_openai_responses_and_uses_canonical_default_endpoint() { + let _env = EnvScope::provider_test(&[("OPENAI_API_KEY", Some(OsStr::new("test-key")))]); + let config = json!({ + "models": {"providers": {"openai": {"api": "openai-responses"}}} + }); + + let plan = build_routing_plan(Some(&config), "http://127.0.0.1:43124"); + + assert_eq!( + plan.openai_upstream.as_deref(), + Some(DEFAULT_OPENAI_BASE_URL) + ); + assert_eq!( + plan.provider_overrides["openai"]["baseUrl"], + "http://127.0.0.1:43124/v1" + ); + assert!( + plan.notes + .iter() + .any(|note| note.contains("openai-responses")) + ); +} + +#[test] +fn leaves_custom_unsupported_oauth_and_model_override_providers_unchanged() { + let _env = EnvScope::provider_test(&[]); + let config = json!({ + "models": {"providers": { + "custom-openai": { + "baseUrl": "https://custom.example/v1", + "api": "openai-completions", + "apiKey": "secret" + }, + "openai": { + "api": "openai-responses", + "auth": "oauth" + }, + "anthropic": { + "api": "anthropic-messages", + "apiKey": "secret", + "models": [{"id": "claude", "baseUrl": "https://model.example"}] + } + }} + }); + + let plan = build_routing_plan(Some(&config), "http://127.0.0.1:43125"); + + assert!(plan.provider_overrides.is_empty()); + assert!(plan.openai_upstream.is_none()); + assert!(plan.anthropic_upstream.is_none()); + assert!(plan.notes.iter().any(|note| note.contains("custom-openai"))); + assert!( + plan.notes + .iter() + .any(|note| note.contains("explicit authentication mode is not API-key based")) + ); + assert!(plan.notes.iter().any(|note| note.contains("model-level"))); +} + +#[test] +fn provider_includes_fail_open_without_guessing_an_upstream() { + let _env = EnvScope::provider_test(&[("OPENAI_API_KEY", Some(OsStr::new("test-key")))]); + let config = json!({ + "models": {"providers": {"openai": {"$include": "./openai-provider.json5"}}} + }); + + let plan = build_routing_plan(Some(&config), "http://127.0.0.1:43126"); + + assert!(plan.provider_overrides.is_empty()); + assert!( + plan.notes + .iter() + .any(|note| note.contains("will not guess")) + ); + + let unrelated = json!({ + "plugins": {"$include": "./plugins.json5"}, + "models": {"providers": {"openai": {"api": "openai-responses"}}} + }); + let plan = build_routing_plan(Some(&unrelated), "http://127.0.0.1:43126"); + assert!(plan.provider_overrides.contains_key("openai")); +} + +#[test] +fn explicit_non_http_agent_runtime_is_not_claimed_as_routed() { + let _env = EnvScope::provider_test(&[("OPENAI_API_KEY", Some(OsStr::new("test-key")))]); + let config = json!({ + "models": {"providers": {"openai": { + "api": "openai-responses", + "agentRuntime": {"id": "codex"} + }}} + }); + + let plan = build_routing_plan(Some(&config), "http://127.0.0.1:43126"); + + assert!(plan.provider_overrides.get("openai").is_none()); + assert!(plan.openai_upstream.is_none()); + assert!( + plan.notes + .iter() + .any(|note| note.contains("direct OpenClaw HTTP runtime")) + ); +} + +#[test] +fn substituted_provider_endpoint_is_not_copied_unresolved_into_relay() { + let _env = EnvScope::provider_test(&[("OPENAI_API_KEY", Some(OsStr::new("test-key")))]); + let config = json!({ + "models": {"providers": {"openai": { + "api": "openai-responses", + "baseUrl": "${OPENAI_BASE}/v1" + }}} + }); + + let plan = build_routing_plan(Some(&config), "http://127.0.0.1:43126"); + + assert!(plan.provider_overrides.get("openai").is_none()); + assert!(plan.openai_upstream.is_none()); + assert!( + plan.notes + .iter() + .any(|note| note.contains("environment-substituted baseUrl")) + ); +} + +#[test] +fn agent_model_runtime_policy_is_not_overridden_or_claimed_as_routed() { + let _env = EnvScope::provider_test(&[("OPENAI_API_KEY", Some(OsStr::new("test-key")))]); + let config = json!({ + "agents": { + "defaults": {"models": { + "openai/gpt-5.5": {"agentRuntime": {"id": "codex"}} + }}, + "list": [{ + "id": "main", + "models": { + "anthropic/*": {"agentRuntime": {"id": "claude-cli"}} + } + }] + }, + "models": {"providers": { + "openai": {"api": "openai-responses"}, + "anthropic": {"api": "anthropic-messages", "apiKey": "secret"} + }} + }); + + let plan = build_routing_plan(Some(&config), "http://127.0.0.1:43126"); + + assert!(plan.provider_overrides.is_empty()); + assert!(plan.openai_upstream.is_none()); + assert!(plan.anthropic_upstream.is_none()); + assert_eq!( + plan.notes + .iter() + .filter(|note| note.contains("agent model policy")) + .count(), + 2 + ); +} + +#[cfg(unix)] +#[test] +fn temporary_overlay_preserves_source_config_metadata_and_explicit_relay_upstream() { + use std::os::unix::fs::PermissionsExt; + + let temp = tempfile::tempdir().unwrap(); + let source = temp.path().join("openclaw.json5"); + let original = r#"{ + // This comment and formatting must remain byte-for-byte unchanged. + agents: { defaults: { model: { primary: "openai/gpt-test" } } }, + plugins: { load: { paths: ["/existing/openclaw/plugin"] } }, + models: { providers: { openai: { + baseUrl: "https://source-upstream.example/v1", + api: "openai-responses", + apiKey: "configured-secret", + } } }, + } +"#; + std::fs::write(&source, original).unwrap(); + let _env = EnvScope::provider_test(&[(OPENCLAW_CONFIG_PATH, Some(source.as_os_str()))]); + let executable = temp.path().join("openclaw"); + std::fs::write( + &executable, + r#"#!/bin/sh +case "$*" in + "plugins inspect nemo-relay --json") + printf '%s' '{"plugin":{"id":"nemo-relay","enabled":true}}' + ;; + *"models auth list --provider openai --json"*|*"models auth list --provider anthropic --json"*) + printf '%s' '{"profiles":[]}' + ;; + *) exit 1 ;; +esac +"#, + ) + .unwrap(); + let mut permissions = std::fs::metadata(&executable).unwrap().permissions(); + permissions.set_mode(0o755); + std::fs::set_permissions(&executable, permissions).unwrap(); + let mut argv = vec![ + executable.display().to_string(), + "agent".into(), + "--message".into(), + "hello".into(), + ]; + let mut gateway = GatewayConfig { + openai_base_url: "https://explicit-relay-upstream.example/v1".into(), + metadata: Some(json!({"team": "relay"})), + ..GatewayConfig::default() + }; + + let prepared = prepare( + &mut argv, + "http://127.0.0.1:43127", + &mut gateway, + ExplicitUpstreams { + openai: true, + anthropic: false, + }, + false, + ) + .unwrap(); + + assert_eq!(std::fs::read_to_string(&source).unwrap(), original); + assert_eq!( + gateway.openai_base_url, + "https://explicit-relay-upstream.example/v1" + ); + assert_eq!( + gateway.metadata.as_ref().unwrap()["nemo_relay_invocation"]["agent"], + "openclaw" + ); + assert_eq!(gateway.metadata.as_ref().unwrap()["team"], "relay"); + assert_eq!(argv[2], "--local"); + + let overlay_path = prepared + .env + .iter() + .find_map(|(name, value)| (name == OPENCLAW_CONFIG_PATH).then(|| PathBuf::from(value))) + .unwrap(); + let overlay: Value = + serde_json::from_str(&std::fs::read_to_string(&overlay_path).unwrap()).unwrap(); + assert_eq!(overlay["$include"], source.display().to_string()); + assert_eq!( + overlay["models"]["providers"]["openai"]["baseUrl"], + "http://127.0.0.1:43127/v1" + ); + assert_eq!( + overlay["plugins"]["entries"][RELAY_OPENCLAW_PLUGIN_ID]["enabled"], + false + ); + assert_eq!( + overlay["plugins"]["entries"][RELAY_OPENCLAW_BRIDGE_ID]["enabled"], + true + ); + assert_eq!( + overlay["plugins"]["entries"][RELAY_OPENCLAW_BRIDGE_ID]["hooks"]["allowConversationAccess"], + true + ); + let bridge_dir = prepared.temp_dirs.first().unwrap(); + assert_eq!( + overlay["plugins"]["load"]["paths"], + json!([ + "/existing/openclaw/plugin", + bridge_dir.display().to_string() + ]) + ); + assert_eq!( + std::fs::read_to_string(bridge_dir.join("package.json")).unwrap(), + BRIDGE_PACKAGE_JSON + ); + assert_eq!( + std::fs::read_to_string(bridge_dir.join("openclaw.plugin.json")).unwrap(), + BRIDGE_MANIFEST_JSON + ); + let bridge = std::fs::read_to_string(bridge_dir.join("index.js")).unwrap(); + assert!(bridge.contains("/hooks/openclaw")); + assert!(bridge.contains("before_tool_call")); + assert!(bridge.contains("after_tool_call")); + assert!(!bridge.contains("nemo-relay-node")); + assert!(!bridge.contains("registerAgentToolResultMiddleware")); + assert_eq!(overlay_path.parent(), source.parent()); + std::fs::remove_file(&prepared.temp_files[0]).unwrap(); + std::fs::remove_dir_all(bridge_dir).unwrap(); + assert!(!overlay_path.exists()); + assert!(!bridge_dir.exists()); + assert!(source.exists()); +} + +#[test] +fn config_resolution_does_not_fall_back_to_legacy_clawdbot_state() { + let temp = tempfile::tempdir().unwrap(); + let legacy = temp.path().join(".clawdbot/clawdbot.json"); + std::fs::create_dir_all(legacy.parent().unwrap()).unwrap(); + std::fs::write(&legacy, "{}").unwrap(); + let _env = EnvScope::provider_test(&[("OPENCLAW_HOME", Some(temp.path().as_os_str()))]); + + assert_eq!( + resolve_config_path(&["openclaw".into()]).unwrap(), + temp.path().join(".openclaw/openclaw.json") + ); + assert_eq!( + probe_state_dir(&["openclaw".into()]).unwrap(), + temp.path().join(".openclaw") + ); +} + +#[cfg(unix)] +#[test] +fn read_only_probes_detect_deterministic_auth_profiles() { + use std::os::unix::fs::PermissionsExt; + + let temp = tempfile::tempdir().unwrap(); + let source = temp.path().join("openclaw.json5"); + std::fs::write(&source, "{}").unwrap(); + let executable = temp.path().join("openclaw"); + std::fs::write( + &executable, + r#"#!/bin/sh +case "$*" in + *"models auth --agent work list --provider openai --json"*) + printf '%s' '{"profiles":[{"id":"openai:work","type":"api_key"}]}' + ;; + *"models auth list --provider openai --json"*) + printf '%s' '{"profiles":[{"id":"openai:one","type":"api_key"},{"id":"openai:two","type":"api_key"}]}' + ;; + *"models auth list --provider anthropic --json"*) + printf '%s' '{"profiles":[{"id":"anthropic:key","type":"api_key"},{"id":"anthropic:oauth","type":"oauth"}]}' + ;; + *) + exit 2 + ;; +esac +"#, + ) + .unwrap(); + let mut permissions = std::fs::metadata(&executable).unwrap().permissions(); + permissions.set_mode(0o755); + std::fs::set_permissions(&executable, permissions).unwrap(); + let argv = vec![executable.display().to_string()]; + + assert_eq!( + probe_auth_profiles(&argv, &source, None, ProviderFamily::OpenAi), + AuthProfileEvidence::ApiKeyOnly + ); + assert_eq!( + probe_auth_profiles(&argv, &source, None, ProviderFamily::Anthropic), + AuthProfileEvidence::NonApiKeyOrMixed + ); + + let selected = vec![ + executable.display().to_string(), + "agent".into(), + "--agent".into(), + "work".into(), + ]; + assert_eq!(selected_agent_id(&selected), Some("work")); + assert_eq!( + probe_auth_profiles(&selected, &source, None, ProviderFamily::OpenAi), + AuthProfileEvidence::ApiKeyOnly + ); +} + +#[cfg(unix)] +#[test] +fn ambiguous_agent_selection_probes_every_configured_auth_store() { + use std::os::unix::fs::PermissionsExt; + + let temp = tempfile::tempdir().unwrap(); + let source = temp.path().join("openclaw.json5"); + std::fs::write(&source, "{}").unwrap(); + let executable = temp.path().join("openclaw"); + std::fs::write( + &executable, + r#"#!/bin/sh +case "$*" in + "models auth list --provider openai --json"|*"models auth --agent work list --provider openai --json"*) + printf '%s' '{"profiles":[{"type":"api_key"}]}' + ;; + *"models auth --agent personal list --provider openai --json"*) + printf '%s' '{"profiles":[]}' + ;; + *) + exit 2 + ;; +esac +"#, + ) + .unwrap(); + let mut permissions = std::fs::metadata(&executable).unwrap().permissions(); + permissions.set_mode(0o755); + std::fs::set_permissions(&executable, permissions).unwrap(); + let argv = vec![ + executable.display().to_string(), + "agent".into(), + "--session-key".into(), + "agent:personal:main".into(), + ]; + let config = json!({"agents": {"list": [{"id": "work"}, {"id": "personal"}]}}); + + assert_eq!( + probe_auth_profiles(&argv, &source, Some(&config), ProviderFamily::OpenAi), + AuthProfileEvidence::ApiKeyOrNone + ); +} + +#[test] +fn trusted_config_and_global_dotenv_api_keys_enable_profileless_routes() { + assert!(!nonempty_dotenv_value(" # comment")); + assert!(!nonempty_dotenv_value("\"\"")); + let temp = tempfile::tempdir().unwrap(); + let state = temp.path().join("state"); + std::fs::create_dir_all(&state).unwrap(); + std::fs::write( + state.join(".env"), + "# values are never copied into diagnostics\nANTHROPIC_API_KEY=dotenv-secret\n", + ) + .unwrap(); + let _env = EnvScope::provider_test(&[("OPENCLAW_STATE_DIR", Some(state.as_os_str()))]); + let argv = vec!["openclaw".to_string(), "agent".into()]; + let config = json!({ + "env": {"vars": {"OPENAI_API_KEY": "config-secret"}}, + "models": {"providers": { + "openai": {"api": "openai-responses"}, + "anthropic": {"api": "anthropic-messages"} + }} + }); + let trusted = trusted_api_key_sources(&argv, Some(&config)); + + assert!(trusted.openai); + assert!(trusted.anthropic); + let plan = build_routing_plan_with_evidence( + Some(&config), + "http://127.0.0.1:43129", + AuthProfileEvidenceSet::default(), + trusted, + ); + assert!(plan.provider_overrides.contains_key("openai")); + assert!(plan.provider_overrides.contains_key("anthropic")); + assert!(plan.notes.iter().all(|note| !note.contains("secret"))); +} + +#[test] +fn ambiguous_auth_profile_types_prevent_provider_redirection() { + let _env = EnvScope::provider_test(&[("OPENAI_API_KEY", Some(OsStr::new("test-key")))]); + let config = json!({ + "models": {"providers": {"openai": {"api": "openai-responses"}}} + }); + let plan = build_routing_plan_with_auth( + Some(&config), + "http://127.0.0.1:43128", + AuthProfileEvidenceSet { + openai: AuthProfileEvidence::NonApiKeyOrMixed, + anthropic: AuthProfileEvidence::None, + }, + ); + + assert!(plan.provider_overrides.is_empty()); + assert!(plan.notes.iter().any(|note| note.contains("ambiguous"))); + + let explicit_but_unverified = json!({ + "models": {"providers": {"openai": { + "api": "openai-responses", + "apiKey": "configured-but-unverified" + }}} + }); + let plan = build_routing_plan_with_auth( + Some(&explicit_but_unverified), + "http://127.0.0.1:43128", + AuthProfileEvidenceSet { + openai: AuthProfileEvidence::Unknown, + anthropic: AuthProfileEvidence::None, + }, + ); + assert!(plan.provider_overrides.is_empty()); + assert!( + plan.notes + .iter() + .any(|note| note.contains("could not be verified")) + ); +} + +#[test] +fn invocation_metadata_preserves_non_object_user_metadata() { + let mut gateway = GatewayConfig { + metadata: Some(json!(["user", "metadata"])), + ..GatewayConfig::default() + }; + + add_invocation_metadata(&mut gateway); + + let metadata = gateway.metadata.unwrap(); + assert_eq!( + metadata["nemo_relay_original_metadata"], + json!(["user", "metadata"]) + ); + assert_eq!(metadata["nemo_relay_invocation"]["agent"], "openclaw"); +} diff --git a/crates/cli/tests/coverage/server_tests.rs b/crates/cli/tests/coverage/server_tests.rs index 25d7ac598..08a3f540b 100644 --- a/crates/cli/tests/coverage/server_tests.rs +++ b/crates/cli/tests/coverage/server_tests.rs @@ -302,6 +302,32 @@ async fn codex_hook_keeps_codex_response_shape() { assert_eq!(body, json!({})); } +#[tokio::test] +async fn openclaw_bridge_hook_is_accepted() { + let app = router(test_config()); + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/hooks/openclaw") + .header("content-type", "application/json") + .body(Body::from( + json!({ + "session_id": "openclaw-1", + "hook_event_name": "session_start" + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + 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!({})); +} + #[tokio::test] async fn hook_payload_above_axum_default_succeeds_with_relay_default_limit() { let app = router(test_config()); diff --git a/crates/cli/tests/coverage/setup_tests.rs b/crates/cli/tests/coverage/setup_tests.rs index aed26f987..f9a5e2e09 100644 --- a/crates/cli/tests/coverage/setup_tests.rs +++ b/crates/cli/tests/coverage/setup_tests.rs @@ -91,9 +91,9 @@ impl Drop for EnvScope { fn detect_installed_agents_finds_binaries_on_path() { use std::os::unix::fs::PermissionsExt; let temp = tempfile::tempdir().unwrap(); - // Drop stub binaries for two of the three supported agents — confirming detection picks up + // Drop stub binaries for three supported agents — confirming detection picks up // only the ones present and ignores the others. - for exec in ["claude", "hermes"] { + for exec in ["claude", "hermes", "openclaw"] { let path = temp.path().join(exec); std::fs::write(&path, "#!/bin/sh\nexit 0\n").unwrap(); std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap(); @@ -105,6 +105,7 @@ fn detect_installed_agents_finds_binaries_on_path() { let detected = detect_installed_agents_in(Some(temp.path().as_os_str())); assert!(detected.contains(&CodingAgent::ClaudeCode)); assert!(detected.contains(&CodingAgent::Hermes)); + assert!(detected.contains(&CodingAgent::OpenClaw)); assert!(!detected.contains(&CodingAgent::Codex)); } @@ -151,7 +152,11 @@ fn build_config_skips_empty_sections_when_no_backends_selected() { fn build_config_emits_agents_block_with_user_facing_keys() { let answers = SetupAnswers { scope: ConfigScope::Project, - agents: vec![CodingAgent::ClaudeCode, CodingAgent::Codex], + agents: vec![ + CodingAgent::ClaudeCode, + CodingAgent::Codex, + CodingAgent::OpenClaw, + ], hermes_hooks_path: None, }; @@ -163,6 +168,8 @@ fn build_config_emits_agents_block_with_user_facing_keys() { assert!(rendered.contains(r#"command = "claude""#)); assert!(rendered.contains("[agents.codex]")); assert!(rendered.contains(r#"command = "codex""#)); + assert!(rendered.contains("[agents.openclaw]")); + assert!(rendered.contains(r#"command = "openclaw""#)); } #[test] diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 13c2096ad..fe005dca8 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -120,6 +120,10 @@ path = "tests/integration/context_isolation_tests.rs" name = "native_plugin_integration" path = "tests/integration/native_plugin_tests.rs" +[[test]] +name = "plugin_host_builtin_ownership" +path = "tests/integration/plugin_host_builtin_ownership_tests.rs" + [[test]] name = "worker_plugin_integration" path = "tests/integration/worker_plugin_tests.rs" diff --git a/crates/core/src/observability/plugin_component.rs b/crates/core/src/observability/plugin_component.rs index ff8e62197..2e3ba4210 100644 --- a/crates/core/src/observability/plugin_component.rs +++ b/crates/core/src/observability/plugin_component.rs @@ -54,7 +54,7 @@ use crate::observability::otel::{ use crate::plugin::{ ConfigDiagnostic, ConfigPolicy, DiagnosticLevel, Plugin, PluginComponentSpec, PluginError, PluginRegistration, PluginRegistrationContext, Result as PluginResult, UnsupportedBehavior, - deregister_plugin, register_plugin, + deregister_plugin, register_builtin_plugin, }; /// The plugin kind registered by the core crate. @@ -533,13 +533,7 @@ impl Plugin for ObservabilityPlugin { /// automatically before listing, looking up, validating, or initializing plugin /// components, so applications normally do not need to invoke it directly. pub fn register_observability_component() -> PluginResult<()> { - match register_plugin(Arc::new(ObservabilityPlugin)) { - Ok(()) => Ok(()), - Err(PluginError::RegistrationFailed(message)) if message.contains("already registered") => { - Ok(()) - } - Err(err) => Err(err), - } + register_builtin_plugin(Arc::new(ObservabilityPlugin)) } /// Deregisters the observability component kind from the core plugin registry. diff --git a/crates/core/src/plugin.rs b/crates/core/src/plugin.rs index 196ddfb20..0da279ecf 100644 --- a/crates/core/src/plugin.rs +++ b/crates/core/src/plugin.rs @@ -9,10 +9,13 @@ //! - plugin registration contexts for middleware/subscriber installation //! - rollback bookkeeping for registrations created during plugin setup +use std::cell::Cell; use std::collections::{HashMap, HashSet}; use std::fmt; use std::future::Future; +use std::panic::{AssertUnwindSafe, catch_unwind}; use std::pin::Pin; +use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, LazyLock, Mutex, OnceLock, RwLock}; use serde::{Deserialize, Serialize}; @@ -43,12 +46,49 @@ pub use nemo_relay_types::plugin::{ConfigDiagnostic, DiagnosticLevel}; pub mod dynamic; pub use dynamic::*; -type PluginMap = HashMap>; +type PluginMap = HashMap; + +struct RegisteredPlugin { + registration_id: u64, + owner: PluginRegistrationOwner, + plugin: Arc, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum PluginRegistrationOwner { + Builtin, + External, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum PluginDeregistrationOutcome { + Removed, + Missing, + Replaced, +} static PLUGIN_HANDLERS: LazyLock> = LazyLock::new(|| RwLock::new(HashMap::new())); static ACTIVE_PLUGIN_CONFIGURATION: LazyLock>> = LazyLock::new(|| Mutex::new(None)); -static BUILTIN_PLUGIN_REGISTRATION: OnceLock> = OnceLock::new(); +static PLUGIN_MUTATION_OWNER: LazyLock> = + LazyLock::new(|| Mutex::new(PluginMutationOwner::Idle)); +static NEXT_PLUGIN_REGISTRATION_ID: AtomicU64 = AtomicU64::new(1); +static NEXT_PLUGIN_HOST_OWNER_ID: AtomicU64 = AtomicU64::new(1); +static PLUGIN_MUTATION_EXECUTOR: OnceLock = OnceLock::new(); + +type PluginMutationJob = Pin + Send + 'static>>; +type PluginMutationSender = tokio::sync::mpsc::UnboundedSender; + +thread_local! { + static IN_PLUGIN_MUTATION_EXECUTOR: Cell = const { Cell::new(false) }; +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum PluginMutationOwner { + Idle, + Legacy, + Host(u64), +} /// Error type for generic plugin operations. #[derive(Debug, Error)] @@ -723,17 +763,50 @@ pub trait Plugin: Send + Sync + 'static { /// # Notes /// Registration affects future validation and initialization only. pub fn register_plugin(plugin: Arc) -> Result<()> { + register_plugin_with_owner(plugin, PluginRegistrationOwner::External).map(|_| ()) +} + +pub(crate) fn register_plugin_tracked(plugin: Arc) -> Result { + register_plugin_with_owner(plugin, PluginRegistrationOwner::External) +} + +pub(crate) fn register_builtin_plugin(plugin: Arc) -> Result<()> { + register_plugin_with_owner(plugin, PluginRegistrationOwner::Builtin).map(|_| ()) +} + +fn register_plugin_with_owner( + plugin: Arc, + owner: PluginRegistrationOwner, +) -> Result { let mut guard = PLUGIN_HANDLERS .write() .map_err(|err| PluginError::Internal(format!("plugin registry lock poisoned: {err}")))?; let plugin_kind = plugin.plugin_kind().to_string(); - if guard.contains_key(&plugin_kind) { + if let Some(existing) = guard.get(&plugin_kind) { + if owner == PluginRegistrationOwner::Builtin + && existing.owner == PluginRegistrationOwner::Builtin + { + return Ok(existing.registration_id); + } + let ownership = if owner == PluginRegistrationOwner::Builtin { + "reserved builtin " + } else { + "" + }; return Err(PluginError::RegistrationFailed(format!( - "plugin '{plugin_kind}' is already registered" + "{ownership}plugin '{plugin_kind}' is already registered" ))); } - guard.insert(plugin_kind, plugin); - Ok(()) + let registration_id = NEXT_PLUGIN_REGISTRATION_ID.fetch_add(1, Ordering::Relaxed); + guard.insert( + plugin_kind, + RegisteredPlugin { + registration_id, + owner, + plugin, + }, + ); + Ok(registration_id) } /// Registers core-provided plugin kinds. @@ -741,28 +814,12 @@ pub fn register_plugin(plugin: Arc) -> Result<()> { /// Built-in plugins are available to validation and initialization without a /// binding or application-specific registration call. pub fn ensure_builtin_plugins_registered() -> Result<()> { - let register_builtins = || { - crate::observability::plugin_component::register_observability_component()?; - crate::plugins::nemo_guardrails::component::register_nemo_guardrails_component()?; - crate::plugins::model_pricing::register_pricing_component() - }; - match BUILTIN_PLUGIN_REGISTRATION.get_or_init(register_builtins) { - Ok(()) => Ok(()), - Err(err) => Err(clone_cached_plugin_error(err)), - } -} - -fn clone_cached_plugin_error(err: &PluginError) -> PluginError { - match err { - PluginError::InvalidConfig(message) => PluginError::InvalidConfig(message.clone()), - PluginError::Conflict(message) => PluginError::Conflict(message.clone()), - PluginError::NotFound(message) => PluginError::NotFound(message.clone()), - PluginError::Serialization(err) => PluginError::Internal(err.to_string()), - PluginError::Internal(message) => PluginError::Internal(message.clone()), - PluginError::RegistrationFailed(message) => { - PluginError::RegistrationFailed(message.clone()) - } - } + // Registration is idempotent for genuine built-ins. Revalidate on every + // call so a removed built-in is restored, a replacement is rejected, and + // a corrected ownership conflict can be retried without restarting Relay. + crate::observability::plugin_component::register_observability_component()?; + crate::plugins::nemo_guardrails::component::register_nemo_guardrails_component()?; + crate::plugins::model_pricing::register_pricing_component() } /// Removes a previously registered plugin. @@ -781,11 +838,31 @@ fn clone_cached_plugin_error(err: &PluginError) -> PluginError { /// Active component registrations created by previous initialization calls are /// not removed by this function. pub fn deregister_plugin(plugin_kind: &str) -> bool { + deregister_plugin_checked(plugin_kind).unwrap_or(false) +} + +pub(crate) fn deregister_plugin_checked(plugin_kind: &str) -> Result { PLUGIN_HANDLERS .write() - .ok() - .and_then(|mut guard| guard.remove(plugin_kind)) - .is_some() + .map(|mut guard| guard.remove(plugin_kind).is_some()) + .map_err(|err| PluginError::Internal(format!("plugin registry lock poisoned: {err}"))) +} + +pub(crate) fn deregister_plugin_registration_checked( + plugin_kind: &str, + expected_registration_id: u64, +) -> Result { + let mut guard = PLUGIN_HANDLERS + .write() + .map_err(|err| PluginError::Internal(format!("plugin registry lock poisoned: {err}")))?; + match guard.get(plugin_kind) { + Some(plugin) if plugin.registration_id == expected_registration_id => { + guard.remove(plugin_kind); + Ok(PluginDeregistrationOutcome::Removed) + } + Some(_) => Ok(PluginDeregistrationOutcome::Replaced), + None => Ok(PluginDeregistrationOutcome::Missing), + } } /// Lists registered plugin kinds in sorted order. @@ -822,10 +899,11 @@ pub fn list_plugin_kinds() -> Vec { /// The returned plugin is shared by [`Arc`], so callers receive a cheap clone. pub fn lookup_plugin(plugin_kind: &str) -> Option> { let _ = ensure_builtin_plugins_registered(); - PLUGIN_HANDLERS - .read() - .ok() - .and_then(|guard| guard.get(plugin_kind).cloned()) + PLUGIN_HANDLERS.read().ok().and_then(|guard| { + guard + .get(plugin_kind) + .map(|registered| Arc::clone(®istered.plugin)) + }) } /// Validates a plugin configuration document. @@ -1020,6 +1098,137 @@ pub fn plugin_config_schema() -> Json { /// is removed before the new configuration is activated. #[doc(hidden)] pub async fn initialize_plugins_exact(config: PluginConfig) -> Result { + run_owned_plugin_mutation("plugin initialization", move || async move { + let lease = LegacyPluginMutationLease::acquire()?; + let rollback_failures = Arc::new(Mutex::new(Vec::new())); + let initialization = tokio::spawn(initialize_plugins_exact_inner( + config, + Some(Arc::clone(&rollback_failures)), + )) + .await + .map_err(|error| { + PluginError::Internal(format!("plugin initialization task failed: {error}")) + }); + let result = initialization.and_then(|result| result); + let failures = rollback_failures + .lock() + .map(|failures| failures.clone()) + .unwrap_or_else(|lock_error| { + vec![format!("rollback failure lock poisoned: {lock_error}")] + }); + match result { + Err(error) if !failures.is_empty() => { + std::mem::forget(lease); + Err(PluginError::RegistrationFailed(format!( + concat!( + "{}; initialization rollback was incomplete: {}; plugin ", + "configuration mutations are disabled for this process because callbacks ", + "may remain registered" + ), + error, + failures.join("; ") + ))) + } + result => result, + } + }) + .await +} + +pub(crate) async fn run_owned_plugin_mutation( + operation_name: &'static str, + operation: F, +) -> Result +where + T: Send + 'static, + F: FnOnce() -> Fut + Send + 'static, + Fut: Future> + Send + 'static, +{ + if IN_PLUGIN_MUTATION_EXECUTOR.get() { + return operation().await; + } + + let (result_tx, result_rx) = tokio::sync::oneshot::channel(); + plugin_mutation_executor()? + .send(Box::pin(async move { + let result = tokio::spawn(operation()) + .await + .map_err(|error| { + PluginError::Internal(format!("{operation_name} task failed: {error}")) + }) + .and_then(|result| result); + let _ = result_tx.send(result); + })) + .map_err(|_| { + PluginError::Internal(format!( + "failed to queue {operation_name}: executor stopped" + )) + })?; + result_rx.await.map_err(|_| { + PluginError::Internal(format!( + "{operation_name} task stopped before returning a result" + )) + })? +} + +fn plugin_mutation_executor() -> Result<&'static PluginMutationSender> { + if let Some(sender) = PLUGIN_MUTATION_EXECUTOR.get() { + return Ok(sender); + } + + let (sender, mut receiver) = tokio::sync::mpsc::unbounded_channel::(); + let (startup_tx, startup_rx) = std::sync::mpsc::sync_channel(1); + std::thread::Builder::new() + .name("nemo-relay-plugin-host".into()) + .spawn(move || { + let runtime = match tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + { + Ok(runtime) => runtime, + Err(error) => { + let _ = startup_tx.send(Err(error.to_string())); + return; + } + }; + let _ = startup_tx.send(Ok(())); + IN_PLUGIN_MUTATION_EXECUTOR.set(true); + runtime.block_on(async move { + while let Some(job) = receiver.recv().await { + job.await; + } + }); + }) + .map_err(|error| { + PluginError::Internal(format!("failed to start plugin host executor: {error}")) + })?; + startup_rx + .recv() + .map_err(|error| { + PluginError::Internal(format!( + "plugin host executor stopped during startup: {error}" + )) + })? + .map_err(|error| { + PluginError::Internal(format!("failed to start plugin host runtime: {error}")) + })?; + + Ok(PLUGIN_MUTATION_EXECUTOR.get_or_init(|| sender)) +} + +pub(crate) async fn initialize_plugins_exact_for_host( + config: PluginConfig, + owner_id: u64, + rollback_failures: Arc>>, +) -> Result { + verify_plugin_host_owner(owner_id)?; + initialize_plugins_exact_inner(config, Some(rollback_failures)).await +} + +async fn initialize_plugins_exact_inner( + config: PluginConfig, + rollback_failures: Option>>>, +) -> Result { let report = validate_plugin_config(&config); if report.has_errors() { return Err(PluginError::InvalidConfig(join_error_messages(&report))); @@ -1033,13 +1242,30 @@ pub async fn initialize_plugins_exact(config: PluginConfig) -> Result { store_active_plugin_configuration(config, report.clone(), registrations)?; Ok(report) } - Err(err) => match initialize_plugin_components(&previous_state.config).await { + Err(err) => match initialize_plugin_components_catching_panics( + previous_state.config.clone(), + rollback_failures.clone(), + ) + .await + { Ok(registrations) => { let previous_report = validate_plugin_config(&previous_state.config); store_active_plugin_configuration( @@ -1055,12 +1281,26 @@ pub async fn initialize_plugins_exact(config: PluginConfig) -> Result>>>, +) -> Result> { + tokio::spawn(async move { initialize_plugin_components(&config, rollback_failures).await }) + .await + .map_err(|error| { + PluginError::Internal(format!( + "plugin component initialization task failed: {error}" + )) + })? +} + /// Validates and activates `config` layered on top of the discovered /// `plugins.toml` configuration, so a direct integration sees the same file /// layering as the gateway. `config` wins on conflicts; as a typed document its @@ -1208,22 +1448,178 @@ pub fn user_config_dir() -> Option { /// Clearing active configuration does not remove plugin kinds from the global /// registry. pub fn clear_plugin_configuration() -> Result<()> { + let lease = LegacyPluginMutationLease::acquire()?; + let outcome = clear_plugin_configuration_inner(); + if !outcome.callbacks_cleared { + // Deregistration callbacks are single-use. If one failed, the process + // can no longer prove that replacing configuration is safe. + std::mem::forget(lease); + return Err(PluginError::RegistrationFailed(format!( + concat!( + "{}; plugin configuration mutations are disabled for this process because ", + "callbacks may remain registered" + ), + outcome + .result + .err() + .map(|error| error.to_string()) + .unwrap_or_else(|| "plugin teardown was incomplete".into()) + ))); + } + outcome.result +} + +pub(crate) fn clear_plugin_configuration_for_host(owner_id: u64) -> PluginHostClearOutcome { + if let Err(error) = verify_plugin_host_owner(owner_id) { + return PluginHostClearOutcome { + result: Err(error), + callbacks_cleared: false, + }; + } + clear_plugin_configuration_inner() +} + +pub(crate) struct PluginHostClearOutcome { + pub(crate) result: Result<()>, + pub(crate) callbacks_cleared: bool, +} + +fn clear_plugin_configuration_inner() -> PluginHostClearOutcome { let flush_error = crate::api::runtime::flush_subscribers() .err() .map(|error| error.to_string()); let previous = { - let mut guard = ACTIVE_PLUGIN_CONFIGURATION.lock().map_err(|err| { - PluginError::Internal(format!("active plugin configuration lock poisoned: {err}")) - })?; + let mut guard = match ACTIVE_PLUGIN_CONFIGURATION.lock() { + Ok(guard) => guard, + Err(err) => { + return PluginHostClearOutcome { + result: Err(PluginError::Internal(format!( + "active plugin configuration lock poisoned: {err}" + ))), + callbacks_cleared: false, + }; + } + }; guard.take() }; - if let Some(mut previous_state) = previous { - rollback_registrations(&mut previous_state.registrations); + let deregistration_errors = previous + .map(|mut previous_state| rollback_registrations_checked(&mut previous_state.registrations)) + .unwrap_or_default(); + let callbacks_cleared = deregistration_errors.is_empty(); + let deregistration_error = (!callbacks_cleared).then(|| { + PluginError::RegistrationFailed(format!( + "plugin teardown failed: {}", + deregistration_errors.join("; ") + )) + }); + let result = match (flush_error, deregistration_error) { + (None, None) => Ok(()), + (Some(flush), None) => Err(PluginError::Internal(flush)), + (None, Some(deregister)) => Err(deregister), + (Some(flush), Some(deregister)) => Err(PluginError::RegistrationFailed(format!( + "{deregister}; subscriber flush also failed: {flush}" + ))), + }; + PluginHostClearOutcome { + result, + callbacks_cleared, + } +} + +pub(crate) fn plugin_configuration_is_active() -> Result { + ACTIVE_PLUGIN_CONFIGURATION + .lock() + .map(|guard| guard.is_some()) + .map_err(|err| { + PluginError::Internal(format!("active plugin configuration lock poisoned: {err}")) + }) +} + +pub(crate) struct PluginHostLease { + owner_id: u64, +} + +impl PluginHostLease { + pub(crate) fn owner_id(&self) -> u64 { + self.owner_id } - if let Some(message) = flush_error { - return Err(PluginError::Internal(message)); +} + +impl Drop for PluginHostLease { + fn drop(&mut self) { + if let Ok(mut owner) = PLUGIN_MUTATION_OWNER.lock() + && *owner == PluginMutationOwner::Host(self.owner_id) + { + *owner = PluginMutationOwner::Idle; + } + } +} + +pub(crate) fn acquire_plugin_host_lease() -> Result { + let mut owner = PLUGIN_MUTATION_OWNER.lock().map_err(|err| { + PluginError::Internal(format!("plugin mutation owner lock poisoned: {err}")) + })?; + if *owner != PluginMutationOwner::Idle { + return Err(plugin_mutation_conflict(*owner)); + } + if plugin_configuration_is_active()? { + return Err(PluginError::Conflict( + "plugin configuration is already active; clear it before activating dynamic plugins" + .into(), + )); + } + let owner_id = NEXT_PLUGIN_HOST_OWNER_ID.fetch_add(1, Ordering::Relaxed); + *owner = PluginMutationOwner::Host(owner_id); + Ok(PluginHostLease { owner_id }) +} + +fn verify_plugin_host_owner(owner_id: u64) -> Result<()> { + let owner = PLUGIN_MUTATION_OWNER.lock().map_err(|err| { + PluginError::Internal(format!("plugin mutation owner lock poisoned: {err}")) + })?; + if *owner == PluginMutationOwner::Host(owner_id) { + Ok(()) + } else { + Err(PluginError::Conflict( + "dynamic plugin host no longer owns plugin configuration".into(), + )) + } +} + +struct LegacyPluginMutationLease; + +impl LegacyPluginMutationLease { + fn acquire() -> Result { + let mut owner = PLUGIN_MUTATION_OWNER.lock().map_err(|err| { + PluginError::Internal(format!("plugin mutation owner lock poisoned: {err}")) + })?; + if *owner != PluginMutationOwner::Idle { + return Err(plugin_mutation_conflict(*owner)); + } + *owner = PluginMutationOwner::Legacy; + Ok(Self) } - Ok(()) +} + +impl Drop for LegacyPluginMutationLease { + fn drop(&mut self) { + if let Ok(mut owner) = PLUGIN_MUTATION_OWNER.lock() + && *owner == PluginMutationOwner::Legacy + { + *owner = PluginMutationOwner::Idle; + } + } +} + +fn plugin_mutation_conflict(owner: PluginMutationOwner) -> PluginError { + let message = match owner { + PluginMutationOwner::Idle => "plugin configuration is available", + PluginMutationOwner::Legacy => "another plugin configuration mutation is in progress", + PluginMutationOwner::Host(_) => { + "plugin configuration is owned by an active dynamic plugin host" + } + }; + PluginError::Conflict(message.into()) } /// Returns the last successfully configured plugin report. @@ -1249,10 +1645,37 @@ pub fn active_plugin_report() -> Option { /// This is used internally during failed initialization and by /// [`clear_plugin_configuration`]. pub fn rollback_registrations(registrations: &mut Vec) { + let _ = rollback_registrations_checked(registrations); +} + +fn rollback_registrations_checked(registrations: &mut Vec) -> Vec { + let mut errors = Vec::new(); for registration in registrations.iter_mut().rev() { - let _ = (registration.deregister)(); + let failure = match catch_unwind(AssertUnwindSafe(|| (registration.deregister)())) { + Ok(Ok(())) => None, + Ok(Err(error)) => Some(error.to_string()), + Err(payload) => Some(format!( + "deregistration panicked: {}", + panic_payload_message(payload) + )), + }; + if let Some(error) = failure { + errors.push(format!( + "{} registration '{}' could not be removed: {error}", + registration.kind, registration.name + )); + } } registrations.clear(); + errors +} + +fn panic_payload_message(payload: Box) -> String { + payload + .downcast_ref::<&str>() + .map(|message| (*message).to_string()) + .or_else(|| payload.downcast_ref::().cloned()) + .unwrap_or_else(|| "unknown panic payload".into()) } struct ActivePluginConfiguration { @@ -1261,11 +1684,14 @@ struct ActivePluginConfiguration { registrations: Vec, } -async fn initialize_plugin_components(config: &PluginConfig) -> Result> { +async fn initialize_plugin_components( + config: &PluginConfig, + rollback_failures: Option>>>, +) -> Result> { ensure_builtin_plugins_registered()?; let totals = plugin_component_totals(config); let mut ordinals: HashMap<&str, usize> = HashMap::new(); - let mut registrations = vec![]; + let mut registrations = PendingPluginRegistrations::new(rollback_failures.clone()); for component in config .components @@ -1273,7 +1699,6 @@ async fn initialize_plugin_components(config: &PluginConfig) -> Result Result, + rollback_failures: Option>>>, +} + +impl PendingPluginRegistrations { + fn new(rollback_failures: Option>>>) -> Self { + Self { + registrations: Vec::new(), + rollback_failures, } - registrations.extend(ctx.into_registrations()); } - Ok(registrations) + fn extend(&mut self, registrations: Vec) { + self.registrations.extend(registrations); + } + + fn take(&mut self) -> Vec { + std::mem::take(&mut self.registrations) + } +} + +impl Drop for PendingPluginRegistrations { + fn drop(&mut self) { + let errors = rollback_registrations_checked(&mut self.registrations); + record_rollback_failures(self.rollback_failures.as_ref(), errors); + } +} + +struct PendingPluginRegistrationContext { + context: PluginRegistrationContext, + rollback_failures: Option>>>, +} + +impl PendingPluginRegistrationContext { + fn new(namespace: String, rollback_failures: Option>>>) -> Self { + Self { + context: PluginRegistrationContext::with_namespace(namespace), + rollback_failures, + } + } + + fn take(&mut self) -> Vec { + std::mem::take(&mut self.context.registrations) + } +} + +impl Drop for PendingPluginRegistrationContext { + fn drop(&mut self) { + let errors = rollback_registrations_checked(&mut self.context.registrations); + record_rollback_failures(self.rollback_failures.as_ref(), errors); + } +} + +fn record_rollback_failures( + rollback_failures: Option<&Arc>>>, + errors: Vec, +) { + if errors.is_empty() { + return; + } + if let Some(rollback_failures) = rollback_failures + && let Ok(mut recorded) = rollback_failures.lock() + { + recorded.extend(errors); + } } fn store_active_plugin_configuration( diff --git a/crates/core/src/plugin/dynamic.rs b/crates/core/src/plugin/dynamic.rs index 983f95bd1..9e9cd98ba 100644 --- a/crates/core/src/plugin/dynamic.rs +++ b/crates/core/src/plugin/dynamic.rs @@ -18,18 +18,45 @@ pub type DynamicPluginId = String; /// Canonical filename for authored Relay plugin manifests. pub const DYNAMIC_PLUGIN_MANIFEST_FILENAME: &str = "relay-plugin.toml"; +mod host; mod manifest; mod native; mod registry; #[cfg(feature = "worker-grpc")] mod worker; +pub use host::*; pub use manifest::*; pub use native::*; pub use registry::*; #[cfg(feature = "worker-grpc")] pub use worker::*; +#[derive(Debug)] +pub(crate) struct DynamicPluginTeardownOutcome { + pub(crate) errors: Vec, + pub(crate) safe_to_unload: bool, +} + +impl DynamicPluginTeardownOutcome { + pub(crate) fn success() -> Self { + Self { + errors: Vec::new(), + safe_to_unload: true, + } + } + + pub(crate) fn record_error(&mut self, error: impl Into, safe_to_unload: bool) { + self.errors.push(error.into()); + self.safe_to_unload &= safe_to_unload; + } + + pub(crate) fn merge(&mut self, other: Self) { + self.errors.extend(other.errors); + self.safe_to_unload &= other.safe_to_unload; + } +} + /// Plugin execution lane. #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash, Display)] #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] diff --git a/crates/core/src/plugin/dynamic/host.rs b/crates/core/src/plugin/dynamic/host.rs new file mode 100644 index 000000000..c85061bba --- /dev/null +++ b/crates/core/src/plugin/dynamic/host.rs @@ -0,0 +1,406 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Owned activation lifecycle for dynamically loaded plugin components. + +use std::collections::HashSet; +use std::sync::{Arc, Mutex}; + +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value as Json}; + +use crate::plugin::{ + ConfigReport, PluginComponentSpec, PluginConfig, PluginHostLease, Result, + acquire_plugin_host_lease, clear_plugin_configuration_for_host, + ensure_builtin_plugins_registered, initialize_plugins_exact_for_host, + run_owned_plugin_mutation, +}; + +use super::{ + DynamicPluginKind, DynamicPluginTeardownOutcome, NativePluginActivation, NativePluginLoadSpec, + load_native_plugins, +}; + +#[cfg(feature = "worker-grpc")] +use super::{WorkerPluginActivation, WorkerPluginLoadSpec, load_worker_plugins}; + +/// One dynamic plugin component to load and activate in an embedding host. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +pub struct DynamicPluginActivationSpec { + /// Expected plugin identifier from the authored manifest. + pub plugin_id: String, + /// Plugin execution lane. + pub kind: DynamicPluginKind, + /// Path or reference to the authored `relay-plugin.toml`. + pub manifest_ref: String, + /// Relay-managed runtime environment used by Python workers. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub environment_ref: Option, + /// Component-local configuration passed to the dynamically loaded plugin. + #[serde(default)] + pub config: Map, +} + +/// Owns one process-wide dynamic plugin configuration and its loaded runtimes. +/// +/// The activation keeps native libraries and worker processes alive until after +/// all callbacks and subscribers registered from them have been removed. Only +/// one activation may exist in a process at a time. +#[must_use = "dropping the activation clears and unloads its dynamic plugins"] +pub struct PluginHostActivation { + active: bool, + native: Option, + #[cfg(feature = "worker-grpc")] + worker: Option, + claim: Option, +} + +impl PluginHostActivation { + /// Load dynamic plugins and activate them with `config` as one transaction. + /// + /// Dynamic components are appended to the supplied base configuration in + /// specification order. The returned activation must remain alive for as + /// long as code may invoke plugin-provided callbacks. + pub async fn activate( + config: PluginConfig, + dynamic_plugins: I, + ) -> Result<(Self, ConfigReport)> + where + I: IntoIterator, + { + let dynamic_plugins = dynamic_plugins.into_iter().collect::>(); + run_owned_plugin_mutation("dynamic plugin activation", move || async move { + Self::activate_inner(config, dynamic_plugins).await + }) + .await + } + + async fn activate_inner( + mut config: PluginConfig, + dynamic_plugins: Vec, + ) -> Result<(Self, ConfigReport)> { + let claim = acquire_plugin_host_lease()?; + validate_unique_plugin_ids(&dynamic_plugins)?; + + #[cfg(not(feature = "worker-grpc"))] + if let Some(plugin) = dynamic_plugins + .iter() + .find(|plugin| plugin.kind == DynamicPluginKind::Worker) + { + return Err(crate::plugin::PluginError::InvalidConfig(format!( + "worker dynamic plugin '{}' requires the 'worker-grpc' feature", + plugin.plugin_id + ))); + } + + // Builtin registration is cached process-wide. It must complete before + // a dynamic plugin can claim a reserved builtin kind and permanently + // cache a failed builtin registration attempt. + ensure_builtin_plugins_registered()?; + + let native_specs = dynamic_plugins + .iter() + .filter(|plugin| plugin.kind == DynamicPluginKind::RustDynamic) + .map(|plugin| NativePluginLoadSpec { + plugin_id: plugin.plugin_id.clone(), + manifest_ref: plugin.manifest_ref.clone(), + }) + .collect::>(); + let native = (!native_specs.is_empty()) + .then(|| { + load_native_plugins(native_specs) + .map_err(|error| plugin_error_context("native plugin load failed", error)) + }) + .transpose()?; + + #[cfg(feature = "worker-grpc")] + let worker = { + let worker_specs = dynamic_plugins + .iter() + .filter(|plugin| plugin.kind == DynamicPluginKind::Worker) + .map(|plugin| WorkerPluginLoadSpec { + plugin_id: plugin.plugin_id.clone(), + manifest_ref: plugin.manifest_ref.clone(), + environment_ref: plugin.environment_ref.clone(), + config: plugin.config.clone(), + }) + .collect::>(); + (!worker_specs.is_empty()) + .then(|| { + load_worker_plugins(worker_specs) + .map_err(|error| plugin_error_context("worker plugin load failed", error)) + }) + .transpose()? + }; + + config.components.extend( + dynamic_plugins + .into_iter() + .map(|plugin| PluginComponentSpec { + kind: plugin.plugin_id, + enabled: true, + config: plugin.config, + }), + ); + let rollback_failures = Arc::new(Mutex::new(Vec::new())); + let owner_id = claim.owner_id(); + let initialization = tokio::spawn(initialize_plugins_exact_for_host( + config, + owner_id, + Arc::clone(&rollback_failures), + )) + .await + .map_err(|error| { + crate::plugin::PluginError::Internal(format!( + "dynamic plugin initialization task failed: {error}" + )) + }); + let report = match initialization.and_then(|result| result) { + Ok(report) => report, + Err(error) => { + let failures = rollback_failures + .lock() + .map(|failures| failures.clone()) + .unwrap_or_else(|lock_error| { + vec![format!("rollback failure lock poisoned: {lock_error}")] + }); + if failures.is_empty() { + return Err(error); + } + if let Some(native) = native { + std::mem::forget(native); + } + #[cfg(feature = "worker-grpc")] + if let Some(worker) = worker { + std::mem::forget(worker); + } + std::mem::forget(claim); + return Err(crate::plugin::PluginError::RegistrationFailed(format!( + concat!( + "{}; activation rollback was incomplete: {}; the loaded runtimes ", + "were retained because callbacks may remain registered" + ), + error, + failures.join("; ") + ))); + } + }; + + Ok(( + Self { + active: true, + native, + #[cfg(feature = "worker-grpc")] + worker, + claim: Some(claim), + }, + report, + )) + } + + /// Returns whether this activation still owns an active plugin host. + pub fn is_active(&self) -> bool { + self.active + } + + /// Clear registered callbacks before unloading libraries and workers. + pub fn clear(mut self) -> Result<()> { + self.clear_inner() + } + + fn clear_inner(&mut self) -> Result<()> { + if !self.active { + return Ok(()); + } + self.active = false; + let outcome = self + .claim + .as_ref() + .map(|claim| clear_plugin_configuration_for_host(claim.owner_id())) + .unwrap_or(crate::plugin::PluginHostClearOutcome { + result: Ok(()), + callbacks_cleared: true, + }); + let mut errors = outcome + .result + .err() + .map(|error| vec![error.to_string()]) + .unwrap_or_default(); + if !outcome.callbacks_cleared { + // If core could not prove callbacks were removed, intentionally + // retain their code and owner for process lifetime rather than + // unload a library or worker that may still be referenced. + self.retain_loaded_runtimes(); + return Err(retained_runtime_error(errors)); + } + + let mut runtime_outcome = DynamicPluginTeardownOutcome::success(); + if let Some(native) = &mut self.native { + runtime_outcome.merge(native.deregister_plugin_kinds_checked()); + } + #[cfg(feature = "worker-grpc")] + if let Some(worker) = &mut self.worker { + runtime_outcome.merge(worker.deregister_plugin_kinds_checked()); + } + + // A worker cannot be stopped while its registry adapter might still be + // callable. Only begin process shutdown once every kind is known to be + // absent from the registry. + #[cfg(feature = "worker-grpc")] + if runtime_outcome.safe_to_unload + && let Some(worker) = &self.worker + { + runtime_outcome.merge(worker.shutdown_plugins_checked()); + } + errors.extend(runtime_outcome.errors); + + if !runtime_outcome.safe_to_unload { + self.retain_loaded_runtimes(); + return Err(retained_runtime_error(errors)); + } + + // Callback removal and kind deregistration are now complete. Dropping + // the activations unloads libraries and runtimes before releasing the + // process-wide host claim. + self.native.take(); + #[cfg(feature = "worker-grpc")] + self.worker.take(); + self.claim.take(); + + if errors.is_empty() { + Ok(()) + } else { + Err(crate::plugin::PluginError::RegistrationFailed(format!( + "dynamic plugin teardown failed: {}", + errors.join("; ") + ))) + } + } + + fn retain_loaded_runtimes(&mut self) { + if let Some(native) = self.native.take() { + std::mem::forget(native); + } + #[cfg(feature = "worker-grpc")] + if let Some(worker) = self.worker.take() { + std::mem::forget(worker); + } + if let Some(claim) = self.claim.take() { + std::mem::forget(claim); + } + } +} + +fn validate_unique_plugin_ids(dynamic_plugins: &[DynamicPluginActivationSpec]) -> Result<()> { + let mut plugin_ids = HashSet::with_capacity(dynamic_plugins.len()); + for plugin in dynamic_plugins { + if !plugin_ids.insert(plugin.plugin_id.as_str()) { + return Err(crate::plugin::PluginError::InvalidConfig(format!( + "duplicate dynamic plugin id '{}'", + plugin.plugin_id + ))); + } + } + Ok(()) +} + +fn retained_runtime_error(errors: Vec) -> crate::plugin::PluginError { + crate::plugin::PluginError::RegistrationFailed(format!( + concat!( + "{}; the loaded runtimes and activation owner were retained because safe ", + "unloading could not be proven" + ), + if errors.is_empty() { + "dynamic plugin teardown was incomplete".into() + } else { + errors.join("; ") + } + )) +} + +fn plugin_error_context( + prefix: &str, + error: crate::plugin::PluginError, +) -> crate::plugin::PluginError { + use crate::plugin::PluginError; + + match error { + PluginError::InvalidConfig(message) => { + PluginError::InvalidConfig(format!("{prefix}: {message}")) + } + PluginError::Conflict(message) => PluginError::Conflict(format!("{prefix}: {message}")), + PluginError::NotFound(message) => PluginError::NotFound(format!("{prefix}: {message}")), + PluginError::Serialization(error) => { + PluginError::Internal(format!("{prefix}: serialization error: {error}")) + } + PluginError::Internal(message) => PluginError::Internal(format!("{prefix}: {message}")), + PluginError::RegistrationFailed(message) => { + PluginError::RegistrationFailed(format!("{prefix}: {message}")) + } + } +} + +impl Drop for PluginHostActivation { + fn drop(&mut self) { + let _ = self.clear_inner(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::plugin::{PLUGIN_HANDLERS, PLUGIN_MUTATION_OWNER, PluginMutationOwner}; + + struct PoisonedRegistryCleanup; + + impl Drop for PoisonedRegistryCleanup { + fn drop(&mut self) { + PLUGIN_HANDLERS.clear_poison(); + if let Ok(mut owner) = PLUGIN_MUTATION_OWNER.lock() { + *owner = PluginMutationOwner::Idle; + } + } + } + + #[test] + fn unsafe_kind_deregistration_retains_runtime_and_owner() { + let _guard = crate::shared_runtime::runtime_owner_test_mutex() + .lock() + .unwrap_or_else(|error| error.into_inner()); + let _cleanup = PoisonedRegistryCleanup; + let claim = acquire_plugin_host_lease().expect("fixture host should acquire the owner"); + let owner_id = claim.owner_id(); + let activation = PluginHostActivation { + active: true, + native: Some(NativePluginActivation::with_plugin_kind_for_test( + "fixture.poisoned", + )), + #[cfg(feature = "worker-grpc")] + worker: None, + claim: Some(claim), + }; + + std::thread::spawn(|| { + let _registry = PLUGIN_HANDLERS.write().unwrap(); + panic!("poison plugin registry for teardown test"); + }) + .join() + .expect_err("fixture registry writer should panic"); + + let error = activation + .clear() + .expect_err("an uncertain kind deregistration must retain the activation") + .to_string(); + assert!(error.contains("plugin registry lock poisoned"), "{error}"); + assert!(error.contains("activation owner were retained"), "{error}"); + assert_eq!( + *PLUGIN_MUTATION_OWNER.lock().unwrap(), + PluginMutationOwner::Host(owner_id) + ); + assert!(matches!( + acquire_plugin_host_lease(), + Err(crate::plugin::PluginError::Conflict(_)) + )); + } +} diff --git a/crates/core/src/plugin/dynamic/native.rs b/crates/core/src/plugin/dynamic/native.rs index f612e884e..27136d3f6 100644 --- a/crates/core/src/plugin/dynamic/native.rs +++ b/crates/core/src/plugin/dynamic/native.rs @@ -51,11 +51,14 @@ use crate::api::scope::{event as emit_scope_mark, get_handle, pop_scope, push_sc use crate::api::tool::ToolExecutionInterceptOutcome; use crate::error::{FlowError, Result as FlowResult}; use crate::plugin::{ - ConfigDiagnostic, DiagnosticLevel, Plugin, PluginError, PluginRegistrationContext, - deregister_plugin, register_plugin, + ConfigDiagnostic, DiagnosticLevel, Plugin, PluginDeregistrationOutcome, PluginError, + PluginRegistrationContext, deregister_plugin_registration_checked, register_plugin_tracked, }; -use super::{DynamicPluginKind, DynamicPluginManifest, DynamicPluginManifestLoad}; +use super::{ + DynamicPluginKind, DynamicPluginManifest, DynamicPluginManifestLoad, + DynamicPluginTeardownOutcome, +}; /// Native plugin load request derived from host dynamic-plugin state. #[derive(Debug, Clone, PartialEq, Eq)] @@ -73,7 +76,7 @@ pub struct NativePluginLoadSpec { /// runtime callbacks cannot outlive their code. pub struct NativePluginActivation { plugins: Vec>, - plugin_kinds: Vec, + plugin_registrations: Vec<(String, u64)>, } impl NativePluginActivation { @@ -84,12 +87,47 @@ impl NativePluginActivation { /// Consumes the activation and deregisters loaded plugin kinds. pub fn clear(self) {} + + pub(crate) fn deregister_plugin_kinds_checked(&mut self) -> DynamicPluginTeardownOutcome { + let mut outcome = DynamicPluginTeardownOutcome::success(); + let plugin_registrations = std::mem::take(&mut self.plugin_registrations); + for (plugin_kind, registration_id) in plugin_registrations.into_iter().rev() { + match deregister_plugin_registration_checked(&plugin_kind, registration_id) { + Ok(PluginDeregistrationOutcome::Removed) => {} + Ok(PluginDeregistrationOutcome::Missing) => outcome.record_error( + format!( + "native plugin kind '{plugin_kind}' was not registered during teardown" + ), + true, + ), + Ok(PluginDeregistrationOutcome::Replaced) => outcome.record_error( + format!( + "native plugin kind '{plugin_kind}' was replaced during teardown and was left registered" + ), + true, + ), + Err(error) => outcome.record_error( + format!("failed to deregister native plugin kind '{plugin_kind}': {error}"), + false, + ), + } + } + outcome + } + + #[cfg(test)] + pub(super) fn with_plugin_kind_for_test(plugin_kind: impl Into) -> Self { + Self { + plugins: Vec::new(), + plugin_registrations: vec![(plugin_kind.into(), 0)], + } + } } impl Drop for NativePluginActivation { fn drop(&mut self) { - for plugin_kind in self.plugin_kinds.iter().rev() { - let _ = deregister_plugin(plugin_kind); + for (plugin_kind, registration_id) in self.plugin_registrations.iter().rev() { + let _ = deregister_plugin_registration_checked(plugin_kind, *registration_id); } } } @@ -104,18 +142,20 @@ where { let mut activation = NativePluginActivation { plugins: Vec::new(), - plugin_kinds: Vec::new(), + plugin_registrations: Vec::new(), }; for spec in specs { let instance = load_one_native_plugin(&spec)?; let plugin_kind = instance.plugin_kind.clone(); - register_plugin(Arc::new(NativePluginAdapter { + let registration_id = register_plugin_tracked(Arc::new(NativePluginAdapter { plugin_kind: plugin_kind.clone(), allows_multiple_components: instance.allows_multiple_components, instance: instance.clone(), }))?; activation.plugins.push(instance); - activation.plugin_kinds.push(plugin_kind); + activation + .plugin_registrations + .push((plugin_kind, registration_id)); } Ok(activation) } diff --git a/crates/core/src/plugin/dynamic/worker.rs b/crates/core/src/plugin/dynamic/worker.rs index d8424043c..b6ca57c96 100644 --- a/crates/core/src/plugin/dynamic/worker.rs +++ b/crates/core/src/plugin/dynamic/worker.rs @@ -8,6 +8,7 @@ use std::future::Future; use std::path::{Path, PathBuf}; use std::pin::Pin; use std::process::{Child, Command, Stdio}; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Condvar, Mutex}; use std::time::Duration; @@ -65,11 +66,14 @@ use crate::api::tool::ToolExecutionInterceptOutcome; use crate::codec::request::AnnotatedLlmRequest; use crate::error::{FlowError, Result as FlowResult}; use crate::plugin::{ - ConfigDiagnostic, DiagnosticLevel, Plugin, PluginError, PluginRegistrationContext, - deregister_plugin, register_plugin, + ConfigDiagnostic, DiagnosticLevel, Plugin, PluginDeregistrationOutcome, PluginError, + PluginRegistrationContext, deregister_plugin_registration_checked, register_plugin_tracked, }; -use super::{DynamicPluginKind, DynamicPluginManifest, DynamicPluginManifestLoad, WorkerRuntime}; +use super::{ + DynamicPluginKind, DynamicPluginManifest, DynamicPluginManifestLoad, + DynamicPluginTeardownOutcome, WorkerRuntime, +}; const JSON_SCHEMA: &str = "nemo.relay.Json@1"; const EVENT_SCHEMA: &str = "nemo.relay.Event@1"; @@ -118,7 +122,7 @@ pub struct WorkerPluginLoadSpec { /// callbacks cannot outlive the worker activation. pub struct WorkerPluginActivation { plugins: Vec>, - plugin_kinds: Vec, + plugin_registrations: Vec<(String, u64)>, } impl WorkerPluginActivation { @@ -129,12 +133,47 @@ impl WorkerPluginActivation { /// Consumes the activation; deregistration runs from `Drop`. pub fn clear(self) {} + + pub(crate) fn deregister_plugin_kinds_checked(&mut self) -> DynamicPluginTeardownOutcome { + let mut outcome = DynamicPluginTeardownOutcome::success(); + let plugin_registrations = std::mem::take(&mut self.plugin_registrations); + for (plugin_kind, registration_id) in plugin_registrations.into_iter().rev() { + match deregister_plugin_registration_checked(&plugin_kind, registration_id) { + Ok(PluginDeregistrationOutcome::Removed) => {} + Ok(PluginDeregistrationOutcome::Missing) => outcome.record_error( + format!( + "worker plugin kind '{plugin_kind}' was not registered during teardown" + ), + true, + ), + Ok(PluginDeregistrationOutcome::Replaced) => outcome.record_error( + format!( + "worker plugin kind '{plugin_kind}' was replaced during teardown and was left registered" + ), + true, + ), + Err(error) => outcome.record_error( + format!("failed to deregister worker plugin kind '{plugin_kind}': {error}"), + false, + ), + } + } + outcome + } + + pub(crate) fn shutdown_plugins_checked(&self) -> DynamicPluginTeardownOutcome { + let mut outcome = DynamicPluginTeardownOutcome::success(); + for plugin in self.plugins.iter().rev() { + outcome.merge(plugin.shutdown_checked()); + } + outcome + } } impl Drop for WorkerPluginActivation { fn drop(&mut self) { - for plugin_kind in self.plugin_kinds.iter().rev() { - let _ = deregister_plugin(plugin_kind); + for (plugin_kind, registration_id) in self.plugin_registrations.iter().rev() { + let _ = deregister_plugin_registration_checked(plugin_kind, *registration_id); } } } @@ -149,18 +188,20 @@ where { let mut activation = WorkerPluginActivation { plugins: Vec::new(), - plugin_kinds: Vec::new(), + plugin_registrations: Vec::new(), }; for spec in specs { let instance = load_one_worker_plugin(&spec)?; let plugin_kind = instance.plugin_kind.clone(); - register_plugin(Arc::new(WorkerPluginAdapter { + let registration_id = register_plugin_tracked(Arc::new(WorkerPluginAdapter { plugin_kind: plugin_kind.clone(), allows_multiple_components: instance.allows_multiple_components, instance: instance.clone(), }))?; activation.plugins.push(instance); - activation.plugin_kinds.push(plugin_kind); + activation + .plugin_registrations + .push((plugin_kind, registration_id)); } Ok(activation) } @@ -221,32 +262,165 @@ struct WorkerPluginInstance { shutdown: Mutex>>, process: Mutex>, activation_dir: PathBuf, + teardown_started: AtomicBool, } impl Drop for WorkerPluginInstance { fn drop(&mut self) { + let _ = self.shutdown_checked(); + } +} + +impl WorkerPluginInstance { + fn shutdown_checked(&self) -> DynamicPluginTeardownOutcome { + let mut outcome = DynamicPluginTeardownOutcome::success(); + if self.teardown_started.swap(true, Ordering::AcqRel) { + return outcome; + } + let mut client = self.client.clone(); let request = ShutdownRequest { activation_id: self.host_state.activation_id.clone(), auth_token: self.host_state.auth_token.clone(), - reason: "plugin activation dropped".into(), + reason: "plugin activation cleared".into(), }; - let _ = block_on_runtime(self.runtime.runtime(), async move { - worker_rpc(client.shutdown(worker_rpc_request(request))).await - }); - if let Ok(mut shutdown) = self.shutdown.lock() - && let Some(sender) = shutdown.take() - { - let _ = sender.send(()); + match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + block_on_runtime(self.runtime.runtime(), async move { + worker_rpc(client.shutdown(worker_rpc_request(request))).await + }) + })) { + Ok(Ok(_)) => {} + Ok(Err(error)) => outcome.record_error( + format!( + "worker plugin '{}' shutdown RPC failed: {error}", + self.plugin_kind + ), + true, + ), + Err(payload) => outcome.record_error( + format!( + "worker plugin '{}' shutdown RPC panicked: {}", + self.plugin_kind, + panic_payload_message(payload.as_ref()) + ), + true, + ), + } + + match self.shutdown.lock() { + Ok(mut shutdown) => { + if let Some(sender) = shutdown.take() + && sender.send(()).is_err() + { + outcome.record_error( + format!( + "worker plugin '{}' host runtime shutdown channel was closed", + self.plugin_kind + ), + true, + ); + } + } + Err(error) => outcome.record_error( + format!( + "worker plugin '{}' host runtime shutdown lock poisoned: {error}", + self.plugin_kind + ), + true, + ), } - if let Ok(mut process) = self.process.lock() - && let Some(mut child) = process.take() + + self.stop_process_checked(&mut outcome); + if outcome.safe_to_unload + && let Err(error) = std::fs::remove_dir_all(&self.activation_dir) + && error.kind() != std::io::ErrorKind::NotFound { - let _ = child.kill(); - let _ = child.wait(); + outcome.record_error( + format!( + "worker plugin '{}' activation directory cleanup failed for '{}': {error}", + self.plugin_kind, + self.activation_dir.display() + ), + true, + ); } - let _ = std::fs::remove_dir_all(&self.activation_dir); + outcome } + + fn stop_process_checked(&self, outcome: &mut DynamicPluginTeardownOutcome) { + let mut process = match self.process.lock() { + Ok(process) => process, + Err(error) => { + outcome.record_error( + format!( + "worker plugin '{}' process lock poisoned: {error}", + self.plugin_kind + ), + false, + ); + return; + } + }; + let Some(child) = process.as_mut() else { + return; + }; + match child.try_wait() { + Ok(Some(_)) => { + process.take(); + } + Ok(None) => { + if let Err(kill_error) = child.kill() { + match child.try_wait() { + Ok(Some(_)) => { + process.take(); + outcome.record_error( + format!( + "worker plugin '{}' process kill failed after exit: {kill_error}", + self.plugin_kind + ), + true, + ); + } + Ok(None) | Err(_) => outcome.record_error( + format!( + "worker plugin '{}' process kill failed: {kill_error}", + self.plugin_kind + ), + false, + ), + } + return; + } + match child.wait() { + Ok(_) => { + process.take(); + } + Err(error) => outcome.record_error( + format!( + "worker plugin '{}' process wait failed after kill: {error}", + self.plugin_kind + ), + false, + ), + } + } + Err(error) => outcome.record_error( + format!( + "worker plugin '{}' process status check failed: {error}", + self.plugin_kind + ), + false, + ), + } + } +} + +fn panic_payload_message(payload: &(dyn std::any::Any + Send)) -> &str { + payload + .downcast_ref::<&str>() + .copied() + .or_else(|| payload.downcast_ref::().map(String::as_str)) + .unwrap_or("unknown panic payload") } fn load_one_worker_plugin( @@ -426,6 +600,7 @@ fn load_one_worker_plugin( shutdown: Mutex::new(Some(shutdown_tx)), process: Mutex::new(Some(child.take())), activation_dir: activation_dir_guard.keep(), + teardown_started: AtomicBool::new(false), })) } diff --git a/crates/core/src/plugins/model_pricing.rs b/crates/core/src/plugins/model_pricing.rs index ad6b86b05..ae1862a98 100644 --- a/crates/core/src/plugins/model_pricing.rs +++ b/crates/core/src/plugins/model_pricing.rs @@ -14,7 +14,7 @@ use crate::codec::response::{ }; use crate::plugin::{ ConfigDiagnostic, DiagnosticLevel, Plugin, PluginError, PluginRegistration, - PluginRegistrationContext, Result, register_plugin, + PluginRegistrationContext, Result, register_builtin_plugin, }; /// Plugin kind used by the model pricing component. @@ -22,15 +22,7 @@ pub const PRICING_PLUGIN_KIND: &str = "pricing"; /// Registers the built-in model pricing component. pub fn register_pricing_component() -> Result<()> { - match register_plugin(Arc::new(PricingPlugin)) { - Ok(()) => Ok(()), - Err(PluginError::RegistrationFailed(message)) - if message.contains("plugin 'pricing' is already registered") => - { - Ok(()) - } - Err(err) => Err(err), - } + register_builtin_plugin(Arc::new(PricingPlugin)) } struct PricingPlugin; diff --git a/crates/core/src/plugins/nemo_guardrails/component.rs b/crates/core/src/plugins/nemo_guardrails/component.rs index 7ece7fa3d..9e341abf7 100644 --- a/crates/core/src/plugins/nemo_guardrails/component.rs +++ b/crates/core/src/plugins/nemo_guardrails/component.rs @@ -14,7 +14,7 @@ use serde_json::{Map, Value as Json}; use crate::plugin::{ ConfigDiagnostic, ConfigPolicy, DiagnosticLevel, Plugin, PluginComponentSpec, PluginError, PluginRegistrationContext, Result as PluginResult, UnsupportedBehavior, deregister_plugin, - register_plugin, + register_builtin_plugin, }; #[path = "local.rs"] @@ -397,13 +397,7 @@ impl Plugin for NeMoGuardrailsPlugin { /// Registers the `nemo_guardrails` component kind in the plugin registry. pub fn register_nemo_guardrails_component() -> PluginResult<()> { - match register_plugin(Arc::new(NeMoGuardrailsPlugin)) { - Ok(()) => Ok(()), - Err(PluginError::RegistrationFailed(message)) if message.contains("already registered") => { - Ok(()) - } - Err(err) => Err(err), - } + register_builtin_plugin(Arc::new(NeMoGuardrailsPlugin)) } /// Deregisters the `nemo_guardrails` component kind from the plugin registry. diff --git a/crates/core/tests/fixtures/native_plugin/src/lib.rs b/crates/core/tests/fixtures/native_plugin/src/lib.rs index 4886a6c96..acca59044 100644 --- a/crates/core/tests/fixtures/native_plugin/src/lib.rs +++ b/crates/core/tests/fixtures/native_plugin/src/lib.rs @@ -260,6 +260,23 @@ fn mark_json(mut value: Json, key: &str) -> Json { nemo_relay_plugin::nemo_relay_plugin!(nemo_relay_fixture_native_plugin, || FixtureNativePlugin); +#[unsafe(no_mangle)] +pub unsafe extern "C" fn nemo_relay_fixture_observability_collision( + host: *const NemoRelayNativeHostApiV1, + out: *mut NemoRelayNativePluginV1, +) -> NemoRelayStatus { + unsafe { + write_raw_descriptor( + host, + out, + "observability", + None, + None, + Some(raw_noop_register), + ) + } +} + #[unsafe(no_mangle)] pub unsafe extern "C" fn nemo_relay_fixture_entry_error( host: *const NemoRelayNativeHostApiV1, diff --git a/crates/core/tests/fixtures/worker_plugin/src/main.rs b/crates/core/tests/fixtures/worker_plugin/src/main.rs index 8fe888cf6..2d84ec907 100644 --- a/crates/core/tests/fixtures/worker_plugin/src/main.rs +++ b/crates/core/tests/fixtures/worker_plugin/src/main.rs @@ -79,6 +79,10 @@ impl WorkerPlugin for FixtureWorkerPlugin { .get("tool_request_error") .and_then(Json::as_bool) .unwrap_or(false); + let exit_in_tool_request = config + .get("exit_in_tool_request") + .and_then(Json::as_bool) + .unwrap_or(false); let llm_request_error = config .get("llm_request_error") .and_then(Json::as_bool) @@ -136,6 +140,9 @@ impl WorkerPlugin for FixtureWorkerPlugin { ctx.register_tool_request_intercept("fixture_rewrite_args", 0, false, { let runtime = runtime.clone(); move |_name, args| { + if exit_in_tool_request { + std::process::exit(44); + } if tool_request_error { return Err(WorkerSdkError::Callback( "fixture tool request error requested".into(), diff --git a/crates/core/tests/integration/native_plugin_tests.rs b/crates/core/tests/integration/native_plugin_tests.rs index 85f6a4b69..34fbc9b57 100644 --- a/crates/core/tests/integration/native_plugin_tests.rs +++ b/crates/core/tests/integration/native_plugin_tests.rs @@ -23,9 +23,14 @@ use nemo_relay::api::scope::{ use nemo_relay::api::subscriber::{deregister_subscriber, flush_subscribers, register_subscriber}; use nemo_relay::api::tool::{ToolCallExecuteParams, tool_call_execute, tool_request_intercepts}; use nemo_relay::codec::response::AnnotatedLlmResponse; -use nemo_relay::plugin::dynamic::{NativePluginLoadSpec, load_native_plugins}; +use nemo_relay::plugin::dynamic::{ + DynamicPluginActivationSpec, DynamicPluginKind, NativePluginLoadSpec, PluginHostActivation, + load_native_plugins, +}; use nemo_relay::plugin::{ - PluginComponentSpec, PluginConfig, clear_plugin_configuration, initialize_plugins_exact, + ConfigDiagnostic, Plugin, PluginComponentSpec, PluginConfig, PluginRegistrationContext, + Result as PluginResult, clear_plugin_configuration, deregister_plugin, + initialize_plugins_exact, list_plugin_kinds, lookup_plugin, register_plugin, }; use serde_json::{Map, Value as Json, json}; use sha2::{Digest, Sha256}; @@ -35,6 +40,34 @@ use uuid::Uuid; static NATIVE_PLUGIN_TEST_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); +struct ReplacementRegistryPlugin; + +impl Plugin for ReplacementRegistryPlugin { + fn plugin_kind(&self) -> &str { + "fixture_native" + } + + fn validate(&self, _plugin_config: &Map) -> Vec { + Vec::new() + } + + fn register<'a>( + &'a self, + _plugin_config: &Map, + _ctx: &'a mut PluginRegistrationContext, + ) -> std::pin::Pin> + Send + 'a>> { + Box::pin(async { Ok(()) }) + } +} + +struct FixtureNativeRegistrationCleanup; + +impl Drop for FixtureNativeRegistrationCleanup { + fn drop(&mut self) { + let _ = deregister_plugin("fixture_native"); + } +} + struct ThreadScopeStackRestore(Option); impl ThreadScopeStackRestore { @@ -964,6 +997,322 @@ async fn native_validate_and_register_callback_errors_are_reported() { } } +#[tokio::test] +async fn plugin_host_activation_owns_configuration_until_clear() { + let _guard = NATIVE_PLUGIN_TEST_LOCK.lock().await; + let fixture = build_fixture_plugin(); + let manifest_ref = write_manifest(&fixture); + let (activation, report) = PluginHostActivation::activate( + PluginConfig::default(), + [host_spec("fixture_native", &manifest_ref)], + ) + .await + .expect("plugin host should activate"); + + assert!(activation.is_active()); + assert!(!report.has_errors()); + assert!( + list_plugin_kinds() + .iter() + .any(|kind| kind == "fixture_native") + ); + let rewritten = tool_request_intercepts("host-owned-tool", json!({ "input": true })) + .expect("host-owned intercept should run"); + assert_eq!(rewritten["native_plugin"], true); + + let initialize_error = initialize_plugins_exact(PluginConfig::default()) + .await + .expect_err("legacy initialize must not replace a host activation") + .to_string(); + assert!(initialize_error.contains("active dynamic plugin host")); + let clear_error = clear_plugin_configuration() + .expect_err("legacy clear must not clear a host activation") + .to_string(); + assert!(clear_error.contains("active dynamic plugin host")); + + activation.clear().expect("plugin host should clear"); + assert!( + !list_plugin_kinds() + .iter() + .any(|kind| kind == "fixture_native") + ); + let unchanged = tool_request_intercepts("host-owned-tool", json!({ "input": true })) + .expect("cleared intercept chain should be empty"); + assert_eq!(unchanged, json!({ "input": true })); +} + +#[tokio::test] +async fn plugin_host_activation_drop_releases_owner_and_plugin_kind() { + let _guard = NATIVE_PLUGIN_TEST_LOCK.lock().await; + let fixture = build_fixture_plugin(); + let manifest_ref = write_manifest(&fixture); + + { + let (activation, _) = PluginHostActivation::activate( + PluginConfig::default(), + [host_spec("fixture_native", &manifest_ref)], + ) + .await + .expect("first plugin host should activate"); + assert!(activation.is_active()); + } + + assert!( + !list_plugin_kinds() + .iter() + .any(|kind| kind == "fixture_native") + ); + let (activation, _) = PluginHostActivation::activate( + PluginConfig::default(), + [host_spec("fixture_native", &manifest_ref)], + ) + .await + .expect("owner should be reusable after drop"); + activation.clear().expect("second plugin host should clear"); +} + +#[tokio::test] +async fn plugin_host_reserved_id_failure_does_not_poison_future_activation() { + let _guard = NATIVE_PLUGIN_TEST_LOCK.lock().await; + let fixture = build_fixture_plugin(); + let collision_manifest = write_manifest_with_plugin_id_and_symbol( + &fixture, + "observability", + "nemo_relay_fixture_observability_collision", + ); + + let error = match PluginHostActivation::activate( + PluginConfig::default(), + [host_spec("observability", &collision_manifest)], + ) + .await + { + Ok((activation, _)) => { + activation + .clear() + .expect("unexpected collision activation should clear"); + panic!("a dynamic plugin must not replace a builtin kind"); + } + Err(error) => error.to_string(), + }; + assert!(error.contains("observability"), "{error}"); + assert!(error.contains("already registered"), "{error}"); + + let manifest_ref = write_manifest(&fixture); + let (activation, _) = PluginHostActivation::activate( + PluginConfig::default(), + [host_spec("fixture_native", &manifest_ref)], + ) + .await + .expect("a reserved-id failure must not poison later activation"); + activation + .clear() + .expect("recovered plugin host should clear"); +} + +#[tokio::test] +async fn plugin_host_rejects_duplicate_ids_before_loading() { + let _guard = NATIVE_PLUGIN_TEST_LOCK.lock().await; + let fixture = build_fixture_plugin(); + let manifest_ref = write_manifest(&fixture); + let spec = host_spec("fixture_native", &manifest_ref); + + let error = + match PluginHostActivation::activate(PluginConfig::default(), [spec.clone(), spec]).await { + Ok((activation, _)) => { + activation + .clear() + .expect("unexpected duplicate activation should clear"); + panic!("duplicate dynamic plugin ids should fail"); + } + Err(error) => error.to_string(), + }; + assert!(error.contains("duplicate dynamic plugin id"), "{error}"); + assert!(error.contains("fixture_native"), "{error}"); + assert!( + !list_plugin_kinds() + .iter() + .any(|kind| kind == "fixture_native") + ); + + let (activation, _) = PluginHostActivation::activate( + PluginConfig::default(), + [host_spec("fixture_native", &manifest_ref)], + ) + .await + .expect("duplicate-id rejection must release the owner"); + activation.clear().expect("recovered host should clear"); +} + +#[tokio::test] +async fn plugin_host_clear_surfaces_missing_kind_and_releases_safe_owner() { + let _guard = NATIVE_PLUGIN_TEST_LOCK.lock().await; + let fixture = build_fixture_plugin(); + let manifest_ref = write_manifest(&fixture); + let (activation, _) = PluginHostActivation::activate( + PluginConfig::default(), + [host_spec("fixture_native", &manifest_ref)], + ) + .await + .expect("plugin host should activate"); + + assert!(deregister_plugin("fixture_native")); + let error = activation + .clear() + .expect_err("missing plugin-kind deregistration should be surfaced") + .to_string(); + assert!(error.contains("fixture_native"), "{error}"); + assert!(error.contains("was not registered"), "{error}"); + + let (activation, _) = PluginHostActivation::activate( + PluginConfig::default(), + [host_spec("fixture_native", &manifest_ref)], + ) + .await + .expect("a safely absent plugin kind should release the owner"); + activation.clear().expect("recovered host should clear"); +} + +#[tokio::test] +async fn plugin_host_clear_preserves_a_replacement_registration() { + let _guard = NATIVE_PLUGIN_TEST_LOCK.lock().await; + let fixture = build_fixture_plugin(); + let manifest_ref = write_manifest(&fixture); + let (activation, _) = PluginHostActivation::activate( + PluginConfig::default(), + [host_spec("fixture_native", &manifest_ref)], + ) + .await + .expect("plugin host should activate"); + + assert!(deregister_plugin("fixture_native")); + let replacement: Arc = Arc::new(ReplacementRegistryPlugin); + register_plugin(Arc::clone(&replacement)).expect("replacement plugin should register"); + let _cleanup = FixtureNativeRegistrationCleanup; + + let error = activation + .clear() + .expect_err("replacement during teardown should be surfaced") + .to_string(); + assert!(error.contains("fixture_native"), "{error}"); + assert!(error.contains("was replaced"), "{error}"); + assert!(error.contains("left registered"), "{error}"); + + let registered = lookup_plugin("fixture_native").expect("replacement must remain registered"); + assert!(Arc::ptr_eq(®istered, &replacement)); + assert!(deregister_plugin("fixture_native")); + + let (activation, _) = PluginHostActivation::activate( + PluginConfig::default(), + [host_spec("fixture_native", &manifest_ref)], + ) + .await + .expect("safe replacement detection should release the host owner"); + activation.clear().expect("recovered host should clear"); +} + +#[tokio::test] +async fn plugin_host_partial_load_failure_rolls_back_and_releases_owner() { + let _guard = NATIVE_PLUGIN_TEST_LOCK.lock().await; + let fixture = build_fixture_plugin(); + let manifest_ref = write_manifest(&fixture); + let missing_manifest = manifest_ref.with_file_name("missing-relay-plugin.toml"); + + let error = match PluginHostActivation::activate( + PluginConfig::default(), + [ + host_spec("fixture_native", &manifest_ref), + host_spec("missing_native", &missing_manifest), + ], + ) + .await + { + Ok((activation, _)) => { + activation + .clear() + .expect("unexpected activation should clear"); + panic!("partial load should fail"); + } + Err(error) => error.to_string(), + }; + assert!(error.contains("missing-relay-plugin.toml"), "{error}"); + assert!( + !list_plugin_kinds() + .iter() + .any(|kind| kind == "fixture_native") + ); + + let (activation, _) = PluginHostActivation::activate( + PluginConfig::default(), + [host_spec("fixture_native", &manifest_ref)], + ) + .await + .expect("failed activation must release the owner"); + activation + .clear() + .expect("recovered activation should clear"); +} + +#[tokio::test] +async fn plugin_host_rejects_an_existing_legacy_configuration() { + let _guard = NATIVE_PLUGIN_TEST_LOCK.lock().await; + initialize_plugins_exact(PluginConfig::default()) + .await + .expect("legacy configuration should initialize"); + + let error = match PluginHostActivation::activate( + PluginConfig::default(), + Vec::::new(), + ) + .await + { + Ok((activation, _)) => { + activation + .clear() + .expect("unexpected activation should clear"); + panic!("host activation should reject an existing configuration"); + } + Err(error) => error.to_string(), + }; + assert!(error.contains("already active"), "{error}"); + clear_plugin_configuration().expect("legacy configuration should clear"); +} + +#[cfg(not(feature = "worker-grpc"))] +#[tokio::test] +async fn plugin_host_rejects_workers_when_worker_support_is_disabled() { + let _guard = NATIVE_PLUGIN_TEST_LOCK.lock().await; + let error = match PluginHostActivation::activate( + PluginConfig::default(), + [DynamicPluginActivationSpec { + plugin_id: "fixture_worker".into(), + kind: DynamicPluginKind::Worker, + manifest_ref: "unused-worker-manifest.toml".into(), + environment_ref: None, + config: Map::new(), + }], + ) + .await + { + Ok((activation, _)) => { + activation + .clear() + .expect("unexpected activation should clear"); + panic!("worker activation should require worker support"); + } + Err(error) => error.to_string(), + }; + assert!(error.contains("worker-grpc"), "{error}"); + + let (activation, _) = PluginHostActivation::activate( + PluginConfig::default(), + Vec::::new(), + ) + .await + .expect("failed worker activation must release the owner"); + activation.clear().expect("empty host should clear"); +} + async fn initialize_fixture_native(config: Map) -> nemo_relay::plugin::Result<()> { let mut plugin_config = PluginConfig::default(); plugin_config.components.push(PluginComponentSpec { @@ -998,6 +1347,16 @@ fn load_spec(plugin_id: &str, manifest_ref: &Path) -> NativePluginLoadSpec { } } +fn host_spec(plugin_id: &str, manifest_ref: &Path) -> DynamicPluginActivationSpec { + DynamicPluginActivationSpec { + plugin_id: plugin_id.into(), + kind: DynamicPluginKind::RustDynamic, + manifest_ref: manifest_ref.to_string_lossy().into_owned(), + environment_ref: None, + config: Map::new(), + } +} + fn assert_parent( events: &[Event], name: &str, @@ -1115,6 +1474,21 @@ fn write_manifest_with_symbol(fixture: &BuiltFixture, symbol: &str) -> PathBuf { }) } +fn write_manifest_with_plugin_id_and_symbol( + fixture: &BuiltFixture, + plugin_id: &str, + symbol: &str, +) -> PathBuf { + write_manifest_text(ManifestOptions { + manifest_dir: fixture.manifest_dir.path(), + plugin_id, + relay: &format!("={}", env!("CARGO_PKG_VERSION")), + library: &fixture.library_path.to_string_lossy(), + symbol, + integrity: None, + }) +} + fn write_manifest_with_plugin_id(fixture: &BuiltFixture, plugin_id: &str) -> PathBuf { write_manifest_text(ManifestOptions { manifest_dir: fixture.manifest_dir.path(), diff --git a/crates/core/tests/integration/plugin_host_builtin_ownership_tests.rs b/crates/core/tests/integration/plugin_host_builtin_ownership_tests.rs new file mode 100644 index 000000000..1d60e0e30 --- /dev/null +++ b/crates/core/tests/integration/plugin_host_builtin_ownership_tests.rs @@ -0,0 +1,73 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Isolated regression coverage for builtin plugin ownership. + +use std::future::Future; +use std::pin::Pin; +use std::sync::Arc; + +use nemo_relay::plugin::dynamic::{DynamicPluginActivationSpec, PluginHostActivation}; +use nemo_relay::plugin::{ + ConfigDiagnostic, Plugin, PluginConfig, PluginRegistrationContext, Result, deregister_plugin, + register_plugin, +}; +use serde_json::{Map, Value as Json}; + +struct PreclaimedObservabilityPlugin; + +impl Plugin for PreclaimedObservabilityPlugin { + fn plugin_kind(&self) -> &str { + "observability" + } + + fn validate(&self, _plugin_config: &Map) -> Vec { + Vec::new() + } + + fn register<'a>( + &'a self, + _plugin_config: &Map, + _ctx: &'a mut PluginRegistrationContext, + ) -> Pin> + Send + 'a>> { + Box::pin(async { Ok(()) }) + } +} + +#[tokio::test] +async fn host_rejects_a_builtin_kind_preclaimed_before_first_ensure() { + register_plugin(Arc::new(PreclaimedObservabilityPlugin)) + .expect("the fixture must preclaim the builtin kind before first ensure"); + + let error = match PluginHostActivation::activate( + PluginConfig::default(), + Vec::::new(), + ) + .await + { + Ok((activation, _)) => { + activation + .clear() + .expect("unexpected host activation should clear"); + panic!("a preclaimed builtin kind must prevent host activation"); + } + Err(error) => error.to_string(), + }; + + assert!( + error.contains("reserved builtin plugin 'observability'"), + "{error}" + ); + assert!(error.contains("already registered"), "{error}"); + assert!(deregister_plugin("observability")); + + let (activation, _) = PluginHostActivation::activate( + PluginConfig::default(), + Vec::::new(), + ) + .await + .expect("host activation should recover after the conflicting registration is removed"); + activation + .clear() + .expect("recovered host activation should clear"); +} diff --git a/crates/core/tests/integration/worker_plugin_tests.rs b/crates/core/tests/integration/worker_plugin_tests.rs index 842b6c191..e16173ef8 100644 --- a/crates/core/tests/integration/worker_plugin_tests.rs +++ b/crates/core/tests/integration/worker_plugin_tests.rs @@ -21,10 +21,12 @@ use nemo_relay::codec::request::AnnotatedLlmRequest; use nemo_relay::codec::traits::LlmCodec; use nemo_relay::error::Result as FlowResult; use nemo_relay::plugin::dynamic::{ - WorkerPluginActivation, WorkerPluginLoadSpec, load_worker_plugins, + DynamicPluginActivationSpec, DynamicPluginKind, PluginHostActivation, WorkerPluginActivation, + WorkerPluginLoadSpec, load_worker_plugins, }; use nemo_relay::plugin::{ PluginComponentSpec, PluginConfig, clear_plugin_configuration, initialize_plugins_exact, + list_plugin_kinds, }; use serde_json::{Map, Value as Json, json}; use sha2::{Digest, Sha256}; @@ -41,6 +43,82 @@ fn worker_activation_with_no_specs_is_empty() { activation.clear(); } +#[tokio::test] +async fn plugin_host_activation_owns_worker_lifecycle() { + let _guard = WORKER_PLUGIN_TEST_LOCK.lock().await; + let fixture = build_fixture_worker(); + let (_manifest_dir, manifest_ref) = write_manifest(fixture.binary_path()); + let (activation, report) = PluginHostActivation::activate( + PluginConfig::default(), + [DynamicPluginActivationSpec { + plugin_id: "fixture_worker".into(), + kind: DynamicPluginKind::Worker, + manifest_ref: manifest_ref.to_string_lossy().into_owned(), + environment_ref: None, + config: Map::new(), + }], + ) + .await + .expect("worker plugin host should activate"); + + assert!(activation.is_active()); + assert!(!report.has_errors()); + assert!( + list_plugin_kinds() + .iter() + .any(|kind| kind == "fixture_worker") + ); + let rewritten = tool_request_intercepts("worker-host-tool", json!({ "input": true })) + .expect("worker host intercept should run"); + assert_eq!(rewritten["worker_plugin"], true); + + activation.clear().expect("worker plugin host should clear"); + assert!( + !list_plugin_kinds() + .iter() + .any(|kind| kind == "fixture_worker") + ); + let unchanged = tool_request_intercepts("worker-host-tool", json!({ "input": true })) + .expect("cleared worker intercept chain should be empty"); + assert_eq!(unchanged, json!({ "input": true })); +} + +#[tokio::test] +async fn plugin_host_clear_surfaces_worker_shutdown_failure_and_releases_safe_owner() { + let _guard = WORKER_PLUGIN_TEST_LOCK.lock().await; + let fixture = build_fixture_worker(); + let (_manifest_dir, manifest_ref) = write_manifest(fixture.binary_path()); + let (activation, _) = PluginHostActivation::activate( + PluginConfig::default(), + [DynamicPluginActivationSpec { + plugin_id: "fixture_worker".into(), + kind: DynamicPluginKind::Worker, + manifest_ref: manifest_ref.to_string_lossy().into_owned(), + environment_ref: None, + config: Map::from_iter([("exit_in_tool_request".into(), json!(true))]), + }], + ) + .await + .expect("worker plugin host should activate"); + + tool_request_intercepts("terminate-worker", json!({ "input": true })) + .expect_err("fixture worker should terminate during callback"); + let error = activation + .clear() + .expect_err("worker shutdown failure should be surfaced") + .to_string(); + assert!(error.contains("fixture_worker"), "{error}"); + assert!(error.contains("shutdown"), "{error}"); + + let (activation, _) = PluginHostActivation::activate( + PluginConfig::default(), + Vec::::new(), + ) + .await + .expect("a stopped worker process should permit owner release"); + activation.clear().expect("recovered host should clear"); +} + #[tokio::test] async fn rust_worker_registers_and_invokes_all_current_surfaces() { let _guard = WORKER_PLUGIN_TEST_LOCK.lock().await; diff --git a/crates/core/tests/unit/dynamic_worker_tests.rs b/crates/core/tests/unit/dynamic_worker_tests.rs index cd8abf3b8..a3c71270d 100644 --- a/crates/core/tests/unit/dynamic_worker_tests.rs +++ b/crates/core/tests/unit/dynamic_worker_tests.rs @@ -1465,6 +1465,7 @@ async fn fake_worker_instance( shutdown: Mutex::new(None), process: Mutex::new(None), activation_dir, + teardown_started: AtomicBool::new(false), }, shutdown_tx, ) diff --git a/crates/core/tests/unit/plugin_tests.rs b/crates/core/tests/unit/plugin_tests.rs index 282c5c571..79569e668 100644 --- a/crates/core/tests/unit/plugin_tests.rs +++ b/crates/core/tests/unit/plugin_tests.rs @@ -9,6 +9,7 @@ use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::{Mutex, OnceLock}; use serde_json::json; +use tokio::sync::Notify; use crate::api::llm::{LlmRequest, LlmRequestInterceptOutcome}; use crate::api::llm::{llm_conditional_execution, llm_request_intercepts}; @@ -26,6 +27,17 @@ struct RestoreFailPlugin; struct RestoreBreakPlugin; struct PartialFailPlugin; struct VanishingPlugin; +struct BlockingPlugin { + started: Arc, + release: Arc, + registered: Arc, +} +struct BackgroundTaskPlugin { + release: Arc, + completed: Arc, +} +struct PanickingPlugin; +struct FailingDeregisterPlugin; static RECORDED_NAMES: OnceLock>> = OnceLock::new(); static PARTIAL_FAIL_ROLLBACKS: AtomicUsize = AtomicUsize::new(0); @@ -289,6 +301,116 @@ impl Plugin for VanishingPlugin { } } +impl Plugin for BlockingPlugin { + fn plugin_kind(&self) -> &str { + "blocking.plugin" + } + + fn validate(&self, _plugin_config: &Map) -> Vec { + vec![ConfigDiagnostic { + level: DiagnosticLevel::Warning, + code: "blocking.warning".into(), + component: Some("blocking.plugin".into()), + field: None, + message: "blocking plugin validated".into(), + }] + } + + fn register<'a>( + &'a self, + _plugin_config: &Map, + ctx: &'a mut PluginRegistrationContext, + ) -> Pin> + Send + 'a>> { + let started = Arc::clone(&self.started); + let release = Arc::clone(&self.release); + let registered = Arc::clone(&self.registered); + Box::pin(async move { + started.notify_one(); + release.notified().await; + ctx.add_registration(PluginRegistration::new( + "plugin", + ctx.qualify_name("blocking"), + Box::new(|| Ok(())), + )); + registered.notify_one(); + Ok(()) + }) + } +} + +impl Plugin for BackgroundTaskPlugin { + fn plugin_kind(&self) -> &str { + "background.task.plugin" + } + + fn validate(&self, _plugin_config: &Map) -> Vec { + vec![] + } + + fn register<'a>( + &'a self, + _plugin_config: &Map, + _ctx: &'a mut PluginRegistrationContext, + ) -> Pin> + Send + 'a>> { + let release = Arc::clone(&self.release); + let completed = Arc::clone(&self.completed); + Box::pin(async move { + tokio::spawn(async move { + release.notified().await; + completed.notify_one(); + }); + Ok(()) + }) + } +} + +impl Plugin for PanickingPlugin { + fn plugin_kind(&self) -> &str { + "panicking.plugin" + } + + fn validate(&self, _plugin_config: &Map) -> Vec { + vec![] + } + + fn register<'a>( + &'a self, + _plugin_config: &Map, + _ctx: &'a mut PluginRegistrationContext, + ) -> Pin> + Send + 'a>> { + Box::pin(async { panic!("fixture plugin panicked during registration") }) + } +} + +impl Plugin for FailingDeregisterPlugin { + fn plugin_kind(&self) -> &str { + "failing.deregister.plugin" + } + + fn validate(&self, _plugin_config: &Map) -> Vec { + vec![] + } + + fn register<'a>( + &'a self, + _plugin_config: &Map, + ctx: &'a mut PluginRegistrationContext, + ) -> Pin> + Send + 'a>> { + Box::pin(async move { + ctx.add_registration(PluginRegistration::new( + "fixture", + ctx.qualify_name("refuses-deregistration"), + Box::new(|| { + Err(PluginError::RegistrationFailed( + "fixture deregistration refused".into(), + )) + }), + )); + Ok(()) + }) + } +} + fn reset_global() { crate::shared_runtime::reset_runtime_owner_for_tests(); let ctx = global_context(); @@ -308,6 +430,10 @@ fn reset_global() { let _ = deregister_plugin("restore.break.plugin"); let _ = deregister_plugin("partial.fail.plugin"); let _ = deregister_plugin("vanishing.plugin"); + let _ = deregister_plugin("blocking.plugin"); + let _ = deregister_plugin("background.task.plugin"); + let _ = deregister_plugin("panicking.plugin"); + let _ = deregister_plugin("failing.deregister.plugin"); } #[test] @@ -821,6 +947,16 @@ fn test_rollback_registrations_runs_in_reverse_and_ignores_failures() { }), )); + let panic_order = Arc::clone(&call_order); + registrations.push(PluginRegistration::new( + "plugin", + "panicking", + Box::new(move || { + panic_order.lock().unwrap().push("panicking"); + panic!("expected rollback panic") + }), + )); + let second_order = Arc::clone(&call_order); registrations.push(PluginRegistration::new( "plugin", @@ -836,7 +972,10 @@ fn test_rollback_registrations_runs_in_reverse_and_ignores_failures() { rollback_registrations(&mut registrations); assert!(registrations.is_empty()); - assert_eq!(*call_order.lock().unwrap(), vec!["second", "first"]); + assert_eq!( + *call_order.lock().unwrap(), + vec!["second", "panicking", "first"] + ); } #[test] @@ -885,6 +1024,46 @@ fn test_initialize_plugins_restores_previous_configuration_after_failed_replacem reset_global(); } +#[test] +fn test_initialize_plugins_restores_previous_configuration_after_replacement_panic() { + let _guard = lock_runtime_owner(); + reset_global(); + register_plugin(Arc::new(RecordingPlugin)).unwrap(); + register_plugin(Arc::new(PanickingPlugin)).unwrap(); + + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + runtime + .block_on(initialize_plugins_exact(PluginConfig { + components: vec![PluginComponentSpec::new("recording.plugin")], + ..PluginConfig::default() + })) + .unwrap(); + + let error = runtime + .block_on(initialize_plugins_exact(PluginConfig { + components: vec![PluginComponentSpec::new("panicking.plugin")], + ..PluginConfig::default() + })) + .unwrap_err(); + assert!( + error.to_string().contains("fixture plugin panicked"), + "{error}" + ); + assert!(active_plugin_report().is_some()); + assert_eq!( + recorded_names().lock().unwrap().as_slice(), + [ + "__nemo_relay_plugin__recording.plugin__subscriber", + "__nemo_relay_plugin__recording.plugin__subscriber", + ] + ); + + reset_global(); +} + #[test] fn test_initialize_plugins_rolls_back_partial_component_registration_on_failure() { let _guard = lock_runtime_owner(); @@ -914,6 +1093,274 @@ fn test_initialize_plugins_rolls_back_partial_component_registration_on_failure( reset_global(); } +#[test] +fn test_initialize_plugins_transaction_finishes_after_caller_cancellation() { + let _guard = lock_runtime_owner(); + reset_global(); + register_plugin(Arc::new(RecordingPlugin)).unwrap(); + let started = Arc::new(Notify::new()); + let release = Arc::new(Notify::new()); + let registered = Arc::new(Notify::new()); + register_plugin(Arc::new(BlockingPlugin { + started: Arc::clone(&started), + release: Arc::clone(&release), + registered: Arc::clone(®istered), + })) + .unwrap(); + + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + runtime.block_on(async { + initialize_plugins_exact(PluginConfig { + components: vec![PluginComponentSpec::new("recording.plugin")], + ..PluginConfig::default() + }) + .await + .unwrap(); + + let caller = tokio::spawn(initialize_plugins_exact(PluginConfig { + components: vec![PluginComponentSpec::new("blocking.plugin")], + ..PluginConfig::default() + })); + started.notified().await; + caller.abort(); + assert!(caller.await.unwrap_err().is_cancelled()); + release.notify_one(); + registered.notified().await; + + tokio::time::timeout(std::time::Duration::from_secs(1), async { + loop { + if active_plugin_report().is_some_and(|report| { + report + .diagnostics + .iter() + .any(|diagnostic| diagnostic.code == "blocking.warning") + }) { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("owned initialization transaction did not finish after caller cancellation"); + }); + + reset_global(); +} + +#[test] +fn test_plugin_runtime_continues_driving_background_tasks_after_initialization() { + let _guard = lock_runtime_owner(); + reset_global(); + let release = Arc::new(Notify::new()); + let completed = Arc::new(Notify::new()); + register_plugin(Arc::new(BackgroundTaskPlugin { + release: Arc::clone(&release), + completed: Arc::clone(&completed), + })) + .unwrap(); + + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + runtime.block_on(async { + initialize_plugins_exact(PluginConfig { + components: vec![PluginComponentSpec::new("background.task.plugin")], + ..PluginConfig::default() + }) + .await + .unwrap(); + + release.notify_one(); + tokio::time::timeout(std::time::Duration::from_secs(1), completed.notified()) + .await + .expect("plugin runtime should keep driving spawned background tasks"); + }); + + reset_global(); +} + +#[test] +fn test_plugin_host_activation_cleans_up_after_caller_cancellation() { + let _guard = lock_runtime_owner(); + reset_global(); + let started = Arc::new(Notify::new()); + let release = Arc::new(Notify::new()); + let registered = Arc::new(Notify::new()); + register_plugin(Arc::new(BlockingPlugin { + started: Arc::clone(&started), + release: Arc::clone(&release), + registered: Arc::clone(®istered), + })) + .unwrap(); + + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + runtime.block_on(async { + let caller = tokio::spawn(dynamic::PluginHostActivation::activate( + PluginConfig { + components: vec![PluginComponentSpec::new("blocking.plugin")], + ..PluginConfig::default() + }, + Vec::::new(), + )); + started.notified().await; + caller.abort(); + match caller.await { + Err(error) => assert!(error.is_cancelled()), + Ok(_) => panic!("plugin host caller should have been canceled"), + } + release.notify_one(); + registered.notified().await; + for _ in 0..100 { + let owner_idle = PLUGIN_MUTATION_OWNER + .lock() + .is_ok_and(|owner| *owner == PluginMutationOwner::Idle); + if owner_idle && active_plugin_report().is_none() { + break; + } + tokio::task::yield_now().await; + } + + // The dropped result must clear its activation and release the lease. + initialize_plugins_exact(PluginConfig::default()) + .await + .expect("canceled host caller should not strand ownership"); + }); + + reset_global(); +} + +#[test] +fn test_pending_registration_records_rollback_failures() { + let failures = Arc::new(Mutex::new(Vec::new())); + { + let mut pending = PendingPluginRegistrations::new(Some(Arc::clone(&failures))); + pending.extend(vec![PluginRegistration::new( + "fixture", + "failed-rollback", + Box::new(|| { + Err(PluginError::RegistrationFailed( + "rollback remained registered".into(), + )) + }), + )]); + } + + let failures = failures.lock().unwrap(); + assert_eq!(failures.len(), 1); + assert!(failures[0].contains("failed-rollback")); + assert!(failures[0].contains("rollback remained registered")); +} + +#[test] +fn test_checked_teardown_reports_unremoved_registrations() { + let _guard = lock_runtime_owner(); + reset_global(); + store_active_plugin_configuration( + PluginConfig::default(), + ConfigReport::default(), + vec![PluginRegistration::new( + "fixture", + "stale-callback", + Box::new(|| { + Err(PluginError::RegistrationFailed( + "deregistration refused".into(), + )) + }), + )], + ) + .unwrap(); + + let outcome = clear_plugin_configuration_inner(); + assert!(!outcome.callbacks_cleared); + let error = outcome.result.unwrap_err().to_string(); + assert!(error.contains("stale-callback"), "{error}"); + assert!(error.contains("deregistration refused"), "{error}"); + assert!(active_plugin_report().is_none()); + reset_global(); +} + +#[test] +fn test_legacy_clear_retains_mutation_owner_after_incomplete_teardown() { + let _guard = lock_runtime_owner(); + reset_global(); + store_active_plugin_configuration( + PluginConfig::default(), + ConfigReport::default(), + vec![PluginRegistration::new( + "fixture", + "stale-callback", + Box::new(|| panic!("fixture deregistration panicked")), + )], + ) + .unwrap(); + + let error = clear_plugin_configuration().unwrap_err(); + assert!(error.to_string().contains("stale-callback"), "{error}"); + assert!( + error + .to_string() + .contains("fixture deregistration panicked"), + "{error}" + ); + assert_eq!( + *PLUGIN_MUTATION_OWNER.lock().unwrap(), + PluginMutationOwner::Legacy + ); + assert!(matches!( + clear_plugin_configuration(), + Err(PluginError::Conflict(_)) + )); + + *PLUGIN_MUTATION_OWNER.lock().unwrap() = PluginMutationOwner::Idle; + reset_global(); +} + +#[test] +fn test_legacy_replace_retains_mutation_owner_after_incomplete_teardown() { + let _guard = lock_runtime_owner(); + reset_global(); + register_plugin(Arc::new(FailingDeregisterPlugin)).unwrap(); + register_plugin(Arc::new(ReplacementPlugin)).unwrap(); + + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + runtime + .block_on(initialize_plugins_exact(PluginConfig { + components: vec![PluginComponentSpec::new("failing.deregister.plugin")], + ..PluginConfig::default() + })) + .unwrap(); + + let error = runtime + .block_on(initialize_plugins_exact(PluginConfig { + components: vec![PluginComponentSpec::new("replacement.plugin")], + ..PluginConfig::default() + })) + .unwrap_err(); + assert!( + error.to_string().contains("fixture deregistration refused"), + "{error}" + ); + assert_eq!(REPLACEMENT_REGISTRATIONS.load(Ordering::SeqCst), 0); + assert_eq!( + *PLUGIN_MUTATION_OWNER.lock().unwrap(), + PluginMutationOwner::Legacy + ); + assert!(active_plugin_report().is_none()); + + *PLUGIN_MUTATION_OWNER.lock().unwrap() = PluginMutationOwner::Idle; + reset_global(); +} + #[test] fn test_initialize_plugins_skips_disabled_components_and_namespaces_multiple_instances() { let _guard = lock_runtime_owner(); diff --git a/crates/node/plugin.d.ts b/crates/node/plugin.d.ts index 7d8ac88d6..dff83903a 100644 --- a/crates/node/plugin.d.ts +++ b/crates/node/plugin.d.ts @@ -45,6 +45,28 @@ export interface PluginConfig { policy?: ConfigPolicy; } +/** Execution lane for a dynamically loaded Relay plugin. */ +export type DynamicPluginKind = 'rust_dynamic' | 'worker'; + +/** Explicitly resolved dynamic plugin load and component configuration. */ +export interface DynamicPluginActivationSpec { + pluginId: string; + kind: DynamicPluginKind; + manifestRef: string; + environmentRef?: string | null; + config?: Record; +} + +/** Owns one process-wide dynamic plugin host activation. */ +export interface DynamicPluginActivation { + /** Validation report produced by the successful activation. */ + readonly report: ConfigReport; + /** Whether this object still owns the dynamic plugin host. */ + readonly active: boolean; + /** Clear callbacks before unloading libraries and workers. Idempotent. */ + close(): Promise; +} + /** A mark Relay materializes under a managed lifecycle. */ export interface PendingMarkSpec { name: string; @@ -213,6 +235,21 @@ export declare function validate(config: PluginConfig): ConfigReport; * the promise rejects with the underlying validation or setup error. */ export declare function initialize(config: PluginConfig): Promise; +/** + * Load and activate explicitly resolved dynamic plugins. + * + * The returned object owns loaded libraries and worker processes. Keep it + * alive while plugin callbacks may run and call `close()` for deterministic + * teardown. Garbage collection is a defensive fallback only. + * + * @param config - Base plugin configuration activated alongside dynamic components. + * @param specs - Explicit manifest and component configuration for each plugin. + * @returns The owned activation and its validation report. + */ +export declare function activateDynamicPlugins( + config: PluginConfig, + specs: DynamicPluginActivationSpec[], +): Promise; /** * Clear the active plugin configuration. * diff --git a/crates/node/plugin.js b/crates/node/plugin.js index a84c3212b..7d1cfbeae 100644 --- a/crates/node/plugin.js +++ b/crates/node/plugin.js @@ -78,6 +78,19 @@ function initialize(config) { return lib.initializePlugins(config); } +/** + * Load and activate explicitly resolved dynamic plugins. + * + * @param {object} config - Base plugin configuration document. + * @param {Array} specs - Native-library or worker plugin load specifications. + * @returns {Promise} An owned activation with `report`, `active`, and `close()`. + * @remarks Keep the returned activation alive while its callbacks may run and + * call `close()` for deterministic teardown. + */ +function activateDynamicPlugins(config, specs) { + return lib.activateDynamicPlugins(config, specs); +} + /** * Clear the active plugin configuration. * @@ -161,6 +174,7 @@ module.exports = { ComponentSpec, validate, initialize, + activateDynamicPlugins, clear, report, listKinds, diff --git a/crates/node/src/api/mod.rs b/crates/node/src/api/mod.rs index efd757fd7..e7272c3e7 100644 --- a/crates/node/src/api/mod.rs +++ b/crates/node/src/api/mod.rs @@ -44,6 +44,10 @@ use nemo_relay::api::tool::ToolAttributes; use nemo_relay::codec::request::AnnotatedLlmRequest; use nemo_relay::codec::response::Usage; use nemo_relay::error::{FlowError, Result as FlowResult}; +use nemo_relay::plugin::dynamic::{ + DynamicPluginActivationSpec as CoreDynamicPluginActivationSpec, DynamicPluginKind, + PluginHostActivation as CorePluginHostActivation, +}; use nemo_relay::plugin::{ ConfigDiagnostic, DiagnosticLevel, Plugin, PluginConfig, PluginError, PluginRegistration, PluginRegistrationContext, active_plugin_report as active_plugin_report_impl, @@ -3744,6 +3748,231 @@ pub async fn initialize_plugins(config: Json) -> napi::Result { serde_json::to_value(&report).map_err(|e| napi::Error::from_reason(e.to_string())) } +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct NodeDynamicPluginActivationSpec { + #[serde(alias = "plugin_id")] + plugin_id: String, + kind: DynamicPluginKind, + #[serde(alias = "manifest_ref")] + manifest_ref: String, + #[serde(default, alias = "environment_ref")] + environment_ref: Option, + #[serde(default)] + config: serde_json::Map, +} + +impl From for CoreDynamicPluginActivationSpec { + fn from(spec: NodeDynamicPluginActivationSpec) -> Self { + Self { + plugin_id: spec.plugin_id, + kind: spec.kind, + manifest_ref: spec.manifest_ref, + environment_ref: spec.environment_ref, + config: spec.config, + } + } +} + +/// Owned dynamic plugin activation. +/// +/// Keep this object alive while code may invoke callbacks registered by the +/// dynamic plugins. Call `close()` for deterministic cleanup; garbage +/// collection performs the same cleanup as a defensive fallback. +#[napi] +pub struct DynamicPluginActivation { + close_state: Arc, + report: Json, +} + +type DynamicPluginTeardownResult = std::result::Result<(), String>; + +enum DynamicPluginCloseStatus { + Active(Option), + Closing, + Closed, +} + +struct DynamicPluginCloseState { + status: StdMutex, + completion: tokio::sync::watch::Sender>, +} + +impl DynamicPluginCloseState { + fn new(activation: CorePluginHostActivation) -> Self { + let (completion, _) = tokio::sync::watch::channel(None); + Self { + status: StdMutex::new(DynamicPluginCloseStatus::Active(Some(activation))), + completion, + } + } + + fn active(&self) -> bool { + let status = self + .status + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + match &*status { + DynamicPluginCloseStatus::Active(activation) => activation.is_some(), + DynamicPluginCloseStatus::Closing | DynamicPluginCloseStatus::Closed => false, + } + } + + fn begin_close(self: &Arc, log_finalizer_error: bool) { + let activation = { + let mut status = self + .status + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + match &mut *status { + DynamicPluginCloseStatus::Active(activation) => { + let activation = activation.take(); + *status = DynamicPluginCloseStatus::Closing; + activation + } + DynamicPluginCloseStatus::Closing | DynamicPluginCloseStatus::Closed => None, + } + }; + let Some(activation) = activation else { + return; + }; + + // Keep the activation outside the spawned closure so a thread-spawn + // failure cannot drop it and synchronously run teardown on the JS thread. + let activation = Arc::new(StdMutex::new(Some(activation))); + let worker_activation = Arc::clone(&activation); + let close_state = Arc::clone(self); + let spawn = std::thread::Builder::new() + .name("nemo-relay-node-plugin-teardown".into()) + .spawn(move || { + let activation = worker_activation + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .take(); + let result = match activation { + Some(activation) => { + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + activation.clear() + })) + .map_err(|_| "dynamic plugin teardown task panicked".to_string()) + .and_then(|result| result.map_err(|error| error.to_string())) + } + None => Err("dynamic plugin teardown task lost its activation".to_string()), + }; + if log_finalizer_error && let Err(error) = &result { + eprintln!("nemo_relay: dynamic plugin finalizer teardown failed: {error}"); + } + close_state.finish(result); + }); + + if let Err(error) = spawn { + // Cleanup must never fall back to the JS thread. Retain the + // activation for process lifetime if no teardown thread can start. + if let Some(activation) = activation + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .take() + { + std::mem::forget(activation); + } + let error = format!("failed to start dynamic plugin teardown task: {error}"); + if log_finalizer_error { + eprintln!("nemo_relay: dynamic plugin finalizer teardown failed: {error}"); + } + self.finish(Err(error)); + } + } + + fn finish(&self, result: DynamicPluginTeardownResult) { + *self + .status + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) = DynamicPluginCloseStatus::Closed; + self.completion.send_replace(Some(result)); + } + + async fn wait_for_close(&self) -> DynamicPluginTeardownResult { + let mut completion = self.completion.subscribe(); + loop { + if let Some(result) = completion.borrow().clone() { + return result; + } + if completion.changed().await.is_err() { + return Err( + "dynamic plugin teardown result channel closed unexpectedly".to_string() + ); + } + } + } +} + +#[napi] +impl DynamicPluginActivation { + /// Return the validation report produced by activation. + #[napi(getter)] + pub fn report(&self) -> Json { + self.report.clone() + } + + /// Return whether this object still owns the dynamic plugin host. + #[napi(getter)] + pub fn active(&self) -> napi::Result { + Ok(self.close_state.active()) + } + + /// Clear plugin callbacks before unloading libraries and workers. + /// + /// This method is idempotent, including when concurrent callers race to + /// close the same activation. + #[napi(ts_return_type = "Promise")] + pub fn close(&self, env: Env) -> napi::Result { + let close_state = Arc::clone(&self.close_state); + close_state.begin_close(false); + env.execute_tokio_future( + async move { + close_state + .wait_for_close() + .await + .map_err(napi::Error::from_reason) + }, + |env, _| env.get_undefined(), + ) + } +} + +impl Drop for DynamicPluginActivation { + fn drop(&mut self) { + self.close_state.begin_close(true); + } +} + +/// Load and activate explicitly resolved dynamic plugins. +/// +/// The returned object owns all loaded libraries and worker processes. Its +/// validation report is available through the `report` property. +#[napi] +pub async fn activate_dynamic_plugins( + config: Json, + specs: Json, +) -> napi::Result { + let config: PluginConfig = serde_json::from_value(config) + .map_err(|error| napi::Error::from_reason(format!("invalid plugin config: {error}")))?; + let specs: Vec = + serde_json::from_value(specs).map_err(|error| { + napi::Error::from_reason(format!("invalid dynamic plugin specs: {error}")) + })?; + let specs = specs.into_iter().map(Into::into).collect::>(); + let (activation, report) = CorePluginHostActivation::activate(config, specs) + .await + .map_err(|error| napi::Error::from_reason(error.to_string()))?; + let report = serde_json::to_value(report) + .map_err(|error| napi::Error::from_reason(error.to_string()))?; + Ok(DynamicPluginActivation { + close_state: Arc::new(DynamicPluginCloseState::new(activation)), + report, + }) +} + /// Clear the active global plugin configuration. #[napi] pub fn clear_plugin_configuration() -> napi::Result<()> { diff --git a/crates/node/tests/dynamic_plugin_tests.mjs b/crates/node/tests/dynamic_plugin_tests.mjs new file mode 100644 index 000000000..b8797a608 --- /dev/null +++ b/crates/node/tests/dynamic_plugin_tests.mjs @@ -0,0 +1,436 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { after, before, describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); +const lib = require('../index.js'); +const plugin = require('../plugin.js'); + +const nodeDir = fileURLToPath(new URL('..', import.meta.url)); +const repoRoot = path.resolve(nodeDir, '../..'); +const fixtureTarget = path.join(repoRoot, 'target', 'node-dynamic-plugin-fixtures'); +const tempRoot = mkdtempSync(path.join(tmpdir(), 'nemo-relay-node-dynamic-')); +const relayVersion = JSON.parse(readFileSync(path.join(nodeDir, 'package.json'), 'utf8')).version; + +let nativeManifestRef; +let workerManifestRef; + +function tomlString(value) { + return JSON.stringify(value); +} + +function nativeLibraryName() { + if (process.platform === 'win32') { + return 'nemo_relay_plugin_fixture.dll'; + } + if (process.platform === 'darwin') { + return 'libnemo_relay_plugin_fixture.dylib'; + } + return 'libnemo_relay_plugin_fixture.so'; +} + +function workerBinaryName() { + return process.platform === 'win32' ? 'nemo-relay-worker-plugin-fixture.exe' : 'nemo-relay-worker-plugin-fixture'; +} + +function buildFixture(manifestPath) { + execFileSync( + process.env.CARGO || 'cargo', + ['build', '--quiet', '--manifest-path', manifestPath, '--target-dir', fixtureTarget], + { stdio: 'inherit' }, + ); +} + +function buildNativeFixture(sourceManifestPath) { + const sourceDirectory = path.dirname(sourceManifestPath); + const fixtureDirectory = path.join(tempRoot, 'native-source'); + const fixtureSourceDirectory = path.join(fixtureDirectory, 'src'); + mkdirSync(fixtureSourceDirectory, { recursive: true }); + const pluginCrate = path.join(repoRoot, 'crates', 'plugin'); + const manifest = readFileSync(sourceManifestPath, 'utf8').replace( + 'nemo-relay-plugin = { path = "../../../../plugin" }', + `nemo-relay-plugin = { path = ${tomlString(pluginCrate)} }`, + ); + const fixtureManifest = path.join(fixtureDirectory, 'Cargo.toml'); + writeFileSync(fixtureManifest, manifest); + writeFileSync(path.join(fixtureSourceDirectory, 'lib.rs'), readFileSync(path.join(sourceDirectory, 'src', 'lib.rs'))); + buildFixture(fixtureManifest); +} + +function writeNativeManifest(libraryPath) { + const directory = path.join(tempRoot, 'native'); + mkdirSync(directory, { recursive: true }); + const manifestRef = path.join(directory, 'relay-plugin.toml'); + writeFileSync( + manifestRef, + `manifest_version = 1 + +[plugin] +id = "fixture_native" +kind = "rust_dynamic" + +[compat] +relay = ${tomlString(`=${relayVersion}`)} +native_api = "1" + +[defaults] +enabled = false + +[capabilities] +items = ["plugin_native"] + +[load] +library = ${tomlString(libraryPath)} +symbol = "nemo_relay_fixture_native_plugin" +`, + ); + return manifestRef; +} + +function writeWorkerManifest(entrypoint, name = 'worker') { + const directory = path.join(tempRoot, name); + mkdirSync(directory, { recursive: true }); + const manifestRef = path.join(directory, 'relay-plugin.toml'); + writeFileSync( + manifestRef, + `manifest_version = 1 + +[plugin] +id = "fixture_worker" +kind = "worker" + +[compat] +relay = ${tomlString(`=${relayVersion}`)} +worker_protocol = "grpc-v1" + +[defaults] +enabled = false + +[capabilities] +items = ["plugin_worker"] + +[load] +runtime = "rust" +entrypoint = ${tomlString(entrypoint)} +`, + ); + return manifestRef; +} + +function writeWorkerWrapper(entrypoint, pidFile, name) { + const wrapper = path.join(tempRoot, `${name}.sh`); + writeFileSync(wrapper, `#!/bin/sh\nprintf '%s' "$$" > ${tomlString(pidFile)}\nexec ${tomlString(entrypoint)}\n`, { + mode: 0o755, + }); + return wrapper; +} + +function activationSpec(pluginId, kind, manifestRef, config = {}) { + return { + pluginId, + kind, + manifestRef, + config, + }; +} + +function nativeRequest(model = 'fixture-model') { + return { + headers: {}, + content: { + model, + messages: [], + }, + }; +} + +async function executeTool(name) { + return lib.toolCallExecute( + name, + { original: true }, + (args) => ({ ...args, downstream: true }), + null, + null, + null, + null, + ); +} + +async function executeLlm(name) { + return lib.llmCallExecute( + name, + nativeRequest(), + (request) => ({ + downstream: true, + requestContent: request.content, + }), + null, + null, + null, + null, + null, + ); +} + +before(() => { + const nativeFixture = path.join(repoRoot, 'crates', 'core', 'tests', 'fixtures', 'native_plugin', 'Cargo.toml'); + const workerFixture = path.join(repoRoot, 'crates', 'core', 'tests', 'fixtures', 'worker_plugin', 'Cargo.toml'); + buildNativeFixture(nativeFixture); + buildFixture(workerFixture); + nativeManifestRef = writeNativeManifest(path.join(fixtureTarget, 'debug', nativeLibraryName())); + workerManifestRef = writeWorkerManifest(path.join(fixtureTarget, 'debug', workerBinaryName())); +}); + +after(() => { + rmSync(tempRoot, { recursive: true, force: true }); +}); + +describe('dynamic plugin host', () => { + it('owns native managed callbacks until idempotent close', async () => { + const activation = await plugin.activateDynamicPlugins({ version: 1, components: [] }, [ + activationSpec('fixture_native', 'rust_dynamic', nativeManifestRef), + ]); + try { + assert.deepEqual(activation.report.diagnostics, []); + assert.equal(activation.active, true); + assert.throws(() => plugin.clear(), /active dynamic plugin host/i); + + const toolResult = await executeTool('node_native_dynamic_tool'); + assert.equal(toolResult.downstream, true); + assert.equal(toolResult.native_plugin_tool_execution_request, true); + assert.equal(toolResult.native_plugin_tool_execution, true); + + const llmResult = await executeLlm('node_native_dynamic_llm'); + assert.equal(llmResult.downstream, true); + assert.equal(llmResult.requestContent.native_plugin_llm_execution_request, true); + assert.equal(llmResult.native_plugin_llm_execution, true); + + await Promise.all([activation.close(), activation.close()]); + assert.equal(activation.active, false); + await activation.close(); + + const toolAfterClose = await executeTool('node_native_closed_tool'); + assert.deepEqual(toolAfterClose, { original: true, downstream: true }); + const llmAfterClose = await executeLlm('node_native_closed_llm'); + assert.equal(llmAfterClose.downstream, true); + assert.equal(llmAfterClose.requestContent.native_plugin_llm_execution_request, undefined); + assert.equal(llmAfterClose.native_plugin_llm_execution, undefined); + } finally { + await activation.close(); + } + }); + + it('owns worker managed callbacks until close', async () => { + const activation = await plugin.activateDynamicPlugins({ version: 1, components: [] }, [ + activationSpec('fixture_worker', 'worker', workerManifestRef), + ]); + try { + assert.deepEqual(activation.report.diagnostics, []); + const toolResult = await executeTool('node_worker_dynamic_tool'); + assert.equal(toolResult.worker_plugin_tool_execution_request, true); + assert.equal(toolResult.worker_plugin_tool_execution, true); + + const llmResult = await executeLlm('node_worker_dynamic_llm'); + assert.equal(llmResult.requestContent.worker_plugin_llm_execution_request, true); + assert.equal(llmResult.worker_plugin_llm_execution, true); + } finally { + await activation.close(); + } + + const toolAfterClose = await executeTool('node_worker_closed_tool'); + assert.deepEqual(toolAfterClose, { original: true, downstream: true }); + const llmAfterClose = await executeLlm('node_worker_closed_llm'); + assert.equal(llmAfterClose.requestContent.worker_plugin_llm_execution_request, undefined); + assert.equal(llmAfterClose.worker_plugin_llm_execution, undefined); + }); + + it( + 'keeps every concurrent close pending until the shared teardown completes', + { skip: process.platform === 'win32' }, + async () => { + const workerBinary = path.join(fixtureTarget, 'debug', workerBinaryName()); + const pidFile = path.join(tempRoot, 'concurrent-close-worker.pid'); + const wrapper = writeWorkerWrapper(workerBinary, pidFile, 'concurrent-close-worker'); + const manifestRef = writeWorkerManifest(wrapper, 'concurrent-close-worker'); + const activation = await plugin.activateDynamicPlugins({ version: 1, components: [] }, [ + activationSpec('fixture_worker', 'worker', manifestRef), + ]); + const workerPid = Number(readFileSync(pidFile, 'utf8')); + process.kill(workerPid, 'SIGSTOP'); + + const firstClose = activation.close(); + const secondClose = activation.close(); + let earlyResult; + try { + earlyResult = await Promise.race([ + firstClose.then(() => 'first'), + secondClose.then(() => 'second'), + new Promise((resolve) => setTimeout(() => resolve('pending'), 200)), + ]); + } finally { + try { + process.kill(workerPid, 'SIGCONT'); + } catch (error) { + if (error.code !== 'ESRCH') { + throw error; + } + } + await Promise.all([firstClose, secondClose]); + } + + assert.equal(earlyResult, 'pending'); + assert.equal(activation.active, false); + await activation.close(); + }, + ); + + it('preserves manifest and validation diagnostics in rejected promises', async () => { + const missingManifest = path.join(tempRoot, 'missing', 'relay-plugin.toml'); + await assert.rejects( + () => + plugin.activateDynamicPlugins({ version: 1, components: [] }, [ + activationSpec('fixture_native', 'rust_dynamic', nativeManifestRef), + activationSpec('missing_native', 'rust_dynamic', missingManifest), + ]), + (error) => { + assert.match(error.message, /native plugin load failed/i); + assert.match(error.message, /relay-plugin\.toml/); + assert.match(error.message, /does not exist/i); + return true; + }, + ); + + await assert.rejects( + () => + plugin.activateDynamicPlugins({ version: 1, components: [] }, [ + activationSpec('fixture_native', 'rust_dynamic', nativeManifestRef, { reject: true }), + ]), + /fixture rejection requested/i, + ); + + const recovered = await plugin.activateDynamicPlugins({ version: 1, components: [] }, [ + activationSpec('fixture_native', 'rust_dynamic', nativeManifestRef), + ]); + await recovered.close(); + }); + + it('defensively clears a native activation during garbage collection', () => { + const pluginModule = path.join(nodeDir, 'plugin.js'); + const script = ` + import { createRequire } from 'node:module'; + const require = createRequire(${JSON.stringify(path.join(nodeDir, 'package.json'))}); + const plugin = require(${JSON.stringify(pluginModule)}); + const config = { version: 1, components: [] }; + const specs = [${JSON.stringify(activationSpec('fixture_native', 'rust_dynamic', nativeManifestRef))}]; + let activation = await plugin.activateDynamicPlugins(config, specs); + const weak = new WeakRef(activation); + activation = null; + let collected = false; + for (let index = 0; index < 100; index += 1) { + global.gc(); + await new Promise((resolve) => setImmediate(resolve)); + if (weak.deref() === undefined) { + collected = true; + break; + } + await new Promise((resolve) => setImmediate(resolve)); + } + if (!collected) { + throw new Error('dynamic activation was not garbage collected'); + } + let replacement; + let lastError; + for (let index = 0; index < 100; index += 1) { + global.gc(); + await new Promise((resolve) => setImmediate(resolve)); + try { + replacement = await plugin.activateDynamicPlugins(config, specs); + break; + } catch (error) { + lastError = error; + } + } + if (!replacement) { + throw lastError ?? new Error('dynamic activation finalizer did not release ownership'); + } + await replacement.close(); + `; + execFileSync(process.execPath, ['--expose-gc', '--input-type=module', '--eval', script], { + stdio: 'inherit', + timeout: 30_000, + }); + }); + + it( + 'never waits for worker teardown on the JavaScript thread during garbage collection', + { skip: process.platform === 'win32' }, + () => { + const workerBinary = path.join(fixtureTarget, 'debug', workerBinaryName()); + const pidFile = path.join(tempRoot, 'finalizer-worker.pid'); + const wrapper = writeWorkerWrapper(workerBinary, pidFile, 'finalizer-worker'); + const manifestRef = writeWorkerManifest(wrapper, 'finalizer-worker'); + const pluginModule = path.join(nodeDir, 'plugin.js'); + const script = ` + import { spawn } from 'node:child_process'; + import { readFileSync } from 'node:fs'; + import { performance } from 'node:perf_hooks'; + import { createRequire } from 'node:module'; + const require = createRequire(${JSON.stringify(path.join(nodeDir, 'package.json'))}); + const plugin = require(${JSON.stringify(pluginModule)}); + const config = { version: 1, components: [] }; + const specs = [${JSON.stringify(activationSpec('fixture_worker', 'worker', manifestRef))}]; + let activation = await plugin.activateDynamicPlugins(config, specs); + const weak = new WeakRef(activation); + activation = null; + await new Promise((resolve) => setImmediate(resolve)); + + const workerPid = Number(readFileSync(${JSON.stringify(pidFile)}, 'utf8')); + process.kill(workerPid, 'SIGSTOP'); + const resumer = spawn( + '/bin/sh', + ['-c', 'sleep 0.8; kill -CONT "$1"', 'resume-worker', String(workerPid)], + { detached: true, stdio: 'ignore' }, + ); + resumer.unref(); + + const startedAt = performance.now(); + global.gc(); + const elapsed = performance.now() - startedAt; + if (weak.deref() !== undefined) { + throw new Error('dynamic activation was not garbage collected'); + } + if (elapsed >= 400) { + throw new Error(\`dynamic activation finalizer blocked the JavaScript thread for \${elapsed}ms\`); + } + + let replacement; + let lastError; + for (let index = 0; index < 500; index += 1) { + await new Promise((resolve) => setTimeout(resolve, 10)); + try { + replacement = await plugin.activateDynamicPlugins(config, specs); + break; + } catch (error) { + lastError = error; + } + } + if (!replacement) { + throw lastError ?? new Error('dynamic activation finalizer did not release ownership'); + } + await replacement.close(); + `; + execFileSync(process.execPath, ['--expose-gc', '--input-type=module', '--eval', script], { + stdio: 'inherit', + timeout: 30_000, + }); + }, + ); +});